Asaas integration
Skill cristianorj22/arthus-harness/tests/snapshots/full-stack/.claude/skills/asaas-integration
Asaas payment integration patterns — webhook HMAC validation, idempotency, error code mapping to pt-BR, log redaction, server-side amount source. Use when touching supabase/functions/asaas-* or src/integrations/asaas/.From its SKILL.md
npx -y skills add cristianorj22/arthus-harness --skill asaas-integrationAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
7.4 KB, ~1.9k tokens by cl100k_base, as published. Nobody here has run it
Asaas integration — pattern
Money in this kind of system is held in escrow until the event happens. A wrong line of code here costs users real BRL.
What this skill enforces
Five non-negotiable rules:
- Webhook authenticity (HMAC, allow-list).
- Idempotency (replay-safe).
- Server-side amount (never trust client).
- Error code translation (pt-BR friendly).
- PII / card data redaction (logs).
1. Webhook authenticity
Every Asaas webhook handler validates origin before doing anything:
// supabase/functions/asaas-webhook/index.ts
import { createHmac } from 'node:crypto';
Deno.serve(async (req) => {
const signature = req.headers.get('asaas-access-token') || '';
const expected = Deno.env.get('ASAAS_WEBHOOK_TOKEN');
if (!signature || signature !== expected) {
return new Response('Unauthorised', { status: 401 });
}
// OR (when Asaas adopts HMAC signing)
// const body = await req.text();
// const sig = req.headers.get('x-asaas-signature') || '';
// const computed = createHmac('sha256', Deno.env.get('ASAAS_WEBHOOK_SECRET')!).update(body).digest('hex');
// if (!timingSafeEqual(sig, computed)) return new Response('Unauthorised', { status: 401 });
// ... continue
});
Without this gate, anyone with the function URL can post fake "payment received" events and credit a host. Treated as CRITICAL vulnerability if missing.
Belt + braces: also validate IP (req.headers.get('x-forwarded-for')) against Asaas IP allow-list when documented.
2. Idempotency
Every webhook delivery may arrive multiple times (Asaas retries on 5xx, 408, 429). Replay must NOT double-credit.
Pattern: store asaas_payment_id (or asaas_charge_id) as UNIQUE and use upsert / on conflict:
-- migration
create table public.payments (
id bigint generated by default as identity primary key,
asaas_payment_id text unique not null, -- ★ idempotency anchor
status text not null,
amount numeric(12, 2) not null,
...
);
-- handler
insert into payments (asaas_payment_id, status, amount, ...)
values (...)
on conflict (asaas_payment_id) do update
set status = excluded.status,
updated_at = now()
where payments.status != excluded.status; -- ★ idempotent: no-op if status unchanged
The webhook handler returns 200 in all idempotent cases — Asaas stops retrying.
3. Server-side amount
The amount charged is always computed server-side from the venue's stored price. Never accept the client's claim.
// ❌ BAD — trusts client
const { amount, venueId, dates } = await req.json();
const charge = await asaas.createPayment({ value: amount, ... });
// ✅ GOOD — server reads price
const { venueId, dates, addOns } = await req.json();
const { data: venue } = await supabase.from('venues').select('price_per_day').eq('id', venueId).single();
const days = computeDays(dates);
const subtotal = venue.price_per_day * days;
const addOnTotal = addOns.reduce((sum, a) => sum + a.price, 0);
const fee = (subtotal + addOnTotal) * PLATFORM_FEE_RATE;
const total = subtotal + addOnTotal + fee;
const charge = await asaas.createPayment({ value: total, ... });
Same pattern for refunds — server computes the refund amount per the venue's cancellation policy, not from a client field.
4. Error code → pt-BR mapping
When Asaas returns an error, surface a friendly Portuguese message — never the raw code:
| Asaas error | User-facing pt-BR |
|---|---|
INSUFFICIENT_FUNDS | "Cartão sem saldo. Tente outro?" |
INVALID_CARD_NUMBER | "Número do cartão parece inválido. Confira os dígitos." |
CARD_DECLINED | "Banco recusou o pagamento. Tente outro cartão ou PIX." |
EXPIRED_CARD | "Esse cartão venceu. Use outro?" |
INVALID_CVV | "Confira o código de segurança do cartão." |
INVALID_HOLDER_NAME | "Nome no cartão precisa bater com o cadastro." |
BLOCKED_CARD | "O banco bloqueou esse cartão. Tente PIX ou outro cartão." |
INVALID_CPF | "CPF inválido. Confira os dígitos." |
| (genérico) | "Não rolou. Tente novamente em alguns instantes." |
Implementation lives in src/integrations/asaas/client.ts (mapAsaasError).
Never expose technical codes to the user. Log them server-side with a request ID for support traceability.
5. Log redaction
Asaas webhooks include card data (last 4 digits OK, full PAN never), CPF (PII), email (PII). Logs and console output redact these fields:
function redactForLog(payload: any): any {
return {
...payload,
customer: payload.customer ? {
name: payload.customer.name,
cpfCnpj: payload.customer.cpfCnpj?.slice(0, 3) + '***' + payload.customer.cpfCnpj?.slice(-2),
email: redactEmail(payload.customer.email),
} : undefined,
creditCard: payload.creditCard ? {
lastFour: payload.creditCard.creditCardNumber?.slice(-4),
brand: payload.creditCard.creditCardBrand,
// never log: number, ccv, holderName
} : undefined,
};
}
console.log('[asaas-webhook]', JSON.stringify(redactForLog(payload)));
Same applies to Sentry / observability — set scrubbing rules on cpfCnpj, creditCard.*, email, phone.
Common silent failures
- ❌ Webhook handler with
try { ... } catch { /* swallow */ }— Asaas thinks it succeeded; we lose the event. - ❌ Returning 500 on a parse error → Asaas retries 10 times → log spam → real issue masked.
- ❌
await asaas.createPayment(...).catch(() => null)— payment failure becomes silent null; user thinks they paid. - ❌ Missing timeout on outbound HTTP. Asaas API down = our edge function hangs 30s → cold-start penalty → user sees spinner forever.
Pattern:
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch(asaasUrl, { signal: controller.signal, ... });
// ...
} catch (err) {
if (err.name === 'AbortError') {
// log + surface "Asaas demorou demais. Tenta de novo?" to user
}
throw err; // re-raise, don't swallow
} finally {
clearTimeout(timeout);
}
Already implemented in src/integrations/asaas/client.ts (asaasRequest).
Audit checklist
When touching Asaas-adjacent code:
- Webhook handler validates origin (token / HMAC).
- Idempotency anchor (
asaas_payment_idor equivalent) is stored UNIQUE. - Replay of same webhook payload does not double-credit / charge.
- Amount comes from server (DB), not from request body.
- All Asaas error codes have pt-BR mapping; no raw code reaches user.
- Logs redact CPF, full card numbers, email, phone.
- Outbound calls to Asaas have 5s timeout + abort.
- Errors are propagated, not swallowed.
- Webhook handler returns 200 on success/idempotent-replay; only 5xx for transient failures.
See also
src/integrations/asaas/client.ts— env-driven Asaas client with timeoutsrc/integrations/asaas/webhook-handler.ts— token validation + redactionsrc/integrations/asaas/idempotency.ts— replay protection- Asaas docs: https://docs.asaas.com/docs/webhooks
What ships with it
Read from the repository
Just SKILL.md. No reference files, no scripts.