Gareth Wilson Gareth Wilson

Guide to Recurrente Webhooks: Features and Best Practices

Published


Recurrente is a payments platform in Guatemala. Businesses use it to take card, bank transfer, crypto, balance and cash payments, run subscriptions, move money between accounts and withdraw to a bank, and platforms use it to manage connected accounts. Its webhooks, delivered through Svix, tell your server when any of that changes state. The official webhook guide is in Spanish.

This guide focuses on what Recurrente does differently: a unified payment format that sits alongside legacy per-method events, no per-endpoint filtering, and Svix signing under svix-* headers.

What are Recurrente webhooks?

A Recurrente webhook endpoint is a public URL registered on your account. Every event is sent as a JSON POST to every registered endpoint; Recurrente documents no way to choose events per endpoint. Each endpoint has its own whsec_ signing secret, and deliveries carry Svix's svix-id, svix-timestamp and svix-signature headers.

Payments use a unified format. One set of intent.* events covers every payment method, with a type field naming the method. Accounts created before Recurrente introduced it still receive the older per-method events, such as payment_intent.succeeded, as well. Which formats you get depends on the account, not the endpoint.

Recurrente webhook features

FeatureDetails
SignatureStandard Webhooks HMAC-SHA256
Signature headersvix-signature
BodyJSON
Event routingBody field event_type
Delivery platformSvix
ConfigurationDashboard or POST /api/webhook_endpoints
FilteringNone documented; every endpoint gets every event
Secretwhsec_, one per endpoint
RetriesImmediately, then 1 min, 5 min, 30 min, 2 h, 6 h
GuaranteeAt least once; svix-id stable across retries
SandboxSeparate test deliveries, unified format only
Timeout, orderingNot documented

Events and payloads

The main events listed in the guide:

EventTriggered WhenCommon Use Cases
intent.succeededA charge succeeds (any method); funds are in your balanceFulfil the order
intent.pendingA charge starts or is pending (bank transfer, crypto)Show "awaiting payment"
intent.failedA charge failsNotify the customer
intent.canceledAn intent is cancelledRelease stock
intent.paidBalance payments only, sent to the paying accountReconcile outgoing balance payments
subscription.createA subscription is created (also emitted with intent.succeeded for recurring products)Provision access
subscription.past_dueThe first automatic charge failsStart dunning
subscription.pause / subscription.unpauseA subscription is paused / resumedSuspend or restore access
subscription.reactivateA cancelled subscription is reactivated within the allowed periodRestore access
subscription.updateGeneral info changes, such as the payment methodSync records
subscription.item_added / subscription.item_removedA recurring product is added or removed (item is the changed item, items the current list)Change entitlements
subscription.invoice_item_addedA one-off charge is added to the next invoiceUpdate billing preview
subscription.cancelThe subscription is cancelled, including after 15 failed charge attemptsDeprovision
setup_intent.succeededA trial subscription starts, or a card is tokenized without a chargeSave the payment method
setup_intent.cancelledA card can't be tokenizedAsk for another card
refund.createA refund is processedReverse fulfilment
dispute.create / dispute.updateA chargeback is opened or changesSubmit evidence
verification.completedA connected account's verification is acceptedEnable the account
verification.rejectedDocuments were rejectedRead document_errors and re-collect
tax_invoice_settings.enabledFEL tax invoicing finished activatingStart issuing invoices
account_connection.createA child account connects to a platformOnboard the account
transfer.sent / transfer.receivedA p2p transfer is sent / receivedReconcile
withdrawal.create / withdrawal.updateA bank withdrawal is created / changes statusTrack payouts
swap.create / swap.updateA stablecoin send is created / changes statusTrack transfers

The guide also lists:

  • Legacy per-method payment events: payment_intent.*, bank_transfer_intent.*, automated_bank_transfer_intent.succeeded, balance_intent.*, crypto_intent.* and cash_intent.succeeded.
  • More transfer states: transfer.pending (automatic review), transfer.in_review (human review) and transfer.cancelled (rejected; the money returns to the sender).
  • Partner commissions: partner_commission_invoice.issued and partner_commission_invoice.failed, for the daily commission tax document.

Recurrente's event catalogue documents more again, so return 200 for any event_type you don't handle. Note the spelling: intent.canceled has one "l", setup_intent.cancelled has two.

A unified intent.succeeded event from the webhook samples library:

{
  "type": "payment",
  "event_type": "intent.succeeded",
  "api_version": "2026-06-01",
  "id": "in_id123",
  "legacy_id": "pa_id123",
  "receipt_number": 100482,
  "status": "succeeded",
  "raw_status": "succeeded",
  "created_at": "2026-06-01T14:03:21Z",
  "customer_id": "cus_id123",
  "user_id": "usr_id123",
  "customer": {
    "id": "cus_id123",
    "email": "cliente@example.com",
    "full_name": "Ana Lopez",
    "nit": "CF",
    "phone": "+50255551234"
  },
  "amount_in_cents": 25000,
  "currency": "GTQ",
  "product": {
    "id": "prod_id123"
  },
  "tax_invoice_url": null,
  "checkout": {
    "id": "ch_id123",
    "status": "paid"
  },
  "payment": {
    "id": "pa_id123"
  },
  "details": {
    "failure_reason": null,
    "fee": 875,
    "tax_invoicing_fee": 0,
    "vat_withheld": 0,
    "vat_withheld_currency": "GTQ",
    "used_presaved_payment_method": false,
    "installments": 1,
    "channel": "Tarjeta",
    "products": [
      {
        "id": "prod_id123",
        "quantity": 1
      }
    ]
  }
}

In unified payment events, type is the payment method (payment, bank_transfer, crypto, balance, cash), status is the normalised state and raw_status the processor's exact value. details varies by method: card payments (payment) carry fees, installments and channel, and bank transfers carry bank_reference and sender_comment. id is the canonical ID; legacy_id is the same charge's ID in the legacy event, which helps link old records during a migration. GET /api/intents/{id} returns the same shape.

Other event families have their own flat shapes, each with event_type and api_version. withdrawal.* and swap.* include a transfer key shaped like GET /api/transfers/{id}. For local withdrawals, a withdrawal.update with status: "sent" is the terminal success; a later completed is only extra confirmation. Connected-account events such as verification.* reach the platform with connected: true and the child's account_id.

Setting up Recurrente webhooks

Endpoints must be public; Recurrente rejects private, local and metadata addresses.

In the dashboard, go to Configuración > Desarrolladores y API, click Webhooks and add your URL, for example https://example.com/webhooks/recurrente. The secret is in the Svix dashboard reachable from the same page, and you can view it there at any time.

Through the API:

curl -X POST https://app.recurrente.com/api/webhook_endpoints \
  -H "X-SECRET-KEY: ..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/webhooks/recurrente",
    "description": "Production webhook"
  }'

The response's signingSecret field holds the whsec_ secret, and the API returns it only in that response. Store it as RECURRENTE_WEBHOOK_SECRET.

To test, create a Sandbox and register an endpoint with its TEST key; Sandbox events only go to those endpoints. Paying a Sandbox checkout with the card 4242 4242 4242 4242 sends intent.succeeded. The Hookdeck CLI lets you receive them locally:

npx hookdeck-cli listen 3000 recurrente --path /webhooks/recurrente

With Hookdeck. Register the URL of a Recurrente source as your endpoint and save its whsec_ secret on the source. Hookdeck verifies the svix-* signature, replies to every POST with 200, and records requests that fail verification without sending them to your service. Methods other than POST are not accepted.

Securing Recurrente webhooks

Recurrente's guide recommends the Svix libraries and also describes manual verification: join svix-id, svix-timestamp and the raw body with dots, compute HMAC-SHA256 with the base64-decoded secret after whsec_, and compare in constant time with the v1, values in svix-signature. In code:

const crypto = require('node:crypto');

function decodeBase64(value) {
  if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
  const bytes = Buffer.from(value, 'base64');
  return bytes.length > 0 && bytes.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')
    ? bytes : null;
}

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const prefix = headers['svix-id'] ? 'svix' : 'webhook';
  const id = headers[`${prefix}-id`];
  const timestamp = headers[`${prefix}-timestamp`];
  const signature = headers[`${prefix}-signature`];
  if (typeof id !== 'string' || !id || typeof timestamp !== 'string' ||
      !/^[0-9]+$/.test(timestamp) || typeof signature !== 'string') return false;
  const key = decodeBase64(secret.replace(/^whsec_/, ''));
  if (!key) return false;
  const message = Buffer.concat([Buffer.from(`${id}.${timestamp}.`, 'utf8'), rawBody]);
  const candidates = signature.split(/\s+/).filter((item) => item.startsWith('v1,')).map((item) => item.slice(3));
  const expected = crypto.createHmac('sha256', key).update(message).digest();
  return candidates.some((candidate) => {
    const received = decodeBase64(candidate);
    if (!received) return false;
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

A handler that copes with both unified and legacy events during a migration:

const express = require('express');

// Returns the parsed body when it is a JSON object, otherwise null.
function parseJsonObject(rawBody) {
  try {
    const value = JSON.parse(rawBody.toString('utf8'));
    return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
  } catch {
    return null;
  }
}

const app = express();

app.post('/webhooks/recurrente', express.raw({ type: '*/*' }), async (req, res) => {
  const secret = process.env.RECURRENTE_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500);
  if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);

  const event = parseJsonObject(req.body);
  if (!event) return res.sendStatus(400); // signed, but not a JSON object
  if (typeof event.event_type !== 'string') return res.sendStatus(400);
  const message_id = req.headers['svix-id'] ?? req.headers['webhook-id'];

  try {
    switch (event.event_type) {
      case 'intent.succeeded':
        await orders.markPaid(message_id, event.id, event.legacy_id, event.amount_in_cents, event.currency);
        break;
      case 'payment_intent.succeeded':
        // Legacy duplicate of a card intent.succeeded on older accounts.
        break;
      case 'subscription.cancel':
        await subscriptions.end(message_id, event);
        break;
      case 'withdrawal.update':
        if (event.withdrawal?.status === 'sent') await payouts.complete(message_id, event);
        break;
      default:
        break;
    }
  } catch {
    return res.sendStatus(503); // not processed, so don't acknowledge
  }
  return res.sendStatus(200);
});

orders, subscriptions and payouts are your own code. Skip the legacy event only once you're sure the account receives the unified one too.

Recurrente calls the timestamp check optional and suggests a window of a few minutes. The Svix libraries it recommends apply five minutes. Hookdeck applies no tolerance and keeps Recurrente's original svix-timestamp on its retries and replays, so a library check behind Hookdeck can reject valid deliveries; use the function above or disable the library's timestamp check.

Make Recurrente webhooks production-ready. Hookdeck Event Gateway gives you per-destination event filtering that Recurrente's endpoints don't have, and keeps payments safe through outages longer than Recurrente's retry schedule.

Recurrente webhook limitations and pain points

Two payment formats on older accounts

The Problem: One payment triggers both intent.succeeded and payment_intent.succeeded, and naive handlers fulfil twice.

Why It Happens: Accounts created before the unified format keep the legacy per-method events, even when they register a new endpoint. Newer accounts and every new Sandbox get only the unified events where a replacement exists, so testing in a Sandbox won't show the duplication.

Workarounds:

  • Handle one format only, and link records with legacy_id.
  • Follow Recurrente's migration guide to move to the unified format.

How Hookdeck Can Help: A filter on event_type can drop the legacy events before they reach your service.

No event filtering per endpoint

The Problem: A service that only cares about withdrawals receives every payment, subscription and dispute.

Why It Happens: Recurrente sends each event to every registered URL and documents no per-endpoint subscription.

Workarounds:

  • Register one endpoint and route internally on event_type.

How Hookdeck Can Help: One Recurrente source can feed several connections, each with its own filter.

Retries stop within about nine hours

The Problem: An outage over a working day loses payment notifications.

Why It Happens: After a failure Recurrente retries immediately and then after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours. The guide doesn't say whether each delay counts from the previous attempt, so the last retry comes roughly 6 to 9 hours later.

Workarounds:

  • Reconcile with GET /api/intents/{id} and the transfers API after an outage.

How Hookdeck Can Help: Hookdeck receives the events while your service is down and retries on your schedule.

The API returns the secret once

The Problem: An endpoint created by script has a secret nobody saved.

Why It Happens: signingSecret appears only in the create response.

Workarounds:

  • Save it in the same script, or read it from the Svix dashboard, which shows it at any time.

How Hookdeck Can Help: Keep the secret on the Hookdeck source, so services behind it don't need it.

Best practices

Fulfil on intent.succeeded

Funds are in your balance when intent.succeeded arrives. Treat intent.pending as information, especially for bank transfers and crypto.

Use svix-id for duplicates

Deliveries can repeat. svix-id stays the same across retries; record it with the change it caused, as our idempotency guide describes.

Know the withdrawal success state

For local withdrawals, act on status: "sent". Don't wait for completed.

Keep Sandbox and live separate

Sandbox events go only to endpoints registered with the Sandbox's TEST key, and each endpoint has its own secret. Use separate URLs or Hookdeck sources for each.

Conclusion

Recurrente's webhooks cover the full range of payments, subscriptions and money movement in Guatemala. Choose one payment format if your account receives both, route on event_type yourself because endpoints can't filter, verify the svix-* signature, and reconcile after any outage that outlasts the retries.

Hookdeck Event Gateway adds filtering, longer retries and a full delivery log. Sign up free and register a Recurrente source from your Sandbox.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.