agentsclimarketplace

Asaas integration

Skill cristianorj22/arthus-harness/plugins/payment-asaas/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

Install
npx -y skills add cristianorj22/arthus-harness --skill asaas-integration

Assembled 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:

  1. Webhook authenticity (HMAC, allow-list).
  2. Idempotency (replay-safe).
  3. Server-side amount (never trust client).
  4. Error code translation (pt-BR friendly).
  5. 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 errorUser-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_id or 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 timeout
  • src/integrations/asaas/webhook-handler.ts — token validation + redaction
  • src/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.

Keep looking

Skills are one crate of 325,949. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.