Guide to Recurrente Webhooks: Features and Best Practices
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
| Feature | Details |
|---|---|
| Signature | Standard Webhooks HMAC-SHA256 |
| Signature header | svix-signature |
| Body | JSON |
| Event routing | Body field event_type |
| Delivery platform | Svix |
| Configuration | Dashboard or POST /api/webhook_ |
| Filtering | None documented; every endpoint gets every event |
| Secret | whsec_, one per endpoint |
| Retries | Immediately, then 1 min, 5 min, 30 min, 2 h, 6 h |
| Guarantee | At least once; svix-id stable across retries |
| Sandbox | Separate test deliveries, unified format only |
| Timeout, ordering | Not documented |
Events and payloads
The main events listed in the guide:
| Event | Triggered When | Common Use Cases |
|---|---|---|
intent.succeeded | A charge succeeds (any method); funds are in your balance | Fulfil the order |
intent.pending | A charge starts or is pending (bank transfer, crypto) | Show "awaiting payment" |
intent.failed | A charge fails | Notify the customer |
intent.canceled | An intent is cancelled | Release stock |
intent.paid | Balance payments only, sent to the paying account | Reconcile outgoing balance payments |
subscription.create | A subscription is created (also emitted with intent.succeeded for recurring products) | Provision access |
subscription.past_due | The first automatic charge fails | Start dunning |
subscription.pause / subscription.unpause | A subscription is paused / resumed | Suspend or restore access |
subscription.reactivate | A cancelled subscription is reactivated within the allowed period | Restore access |
subscription.update | General info changes, such as the payment method | Sync records |
subscription.item_added / subscription.item_ | A recurring product is added or removed (item is the changed item, items the current list) | Change entitlements |
subscription.invoice_ | A one-off charge is added to the next invoice | Update billing preview |
subscription.cancel | The subscription is cancelled, including after 15 failed charge attempts | Deprovision |
setup_intent.succeeded | A trial subscription starts, or a card is tokenized without a charge | Save the payment method |
setup_intent.cancelled | A card can't be tokenized | Ask for another card |
refund.create | A refund is processed | Reverse fulfilment |
dispute.create / dispute.update | A chargeback is opened or changes | Submit evidence |
verification.completed | A connected account's verification is accepted | Enable the account |
verification.rejected | Documents were rejected | Read document_errors and re-collect |
tax_ | FEL tax invoicing finished activating | Start issuing invoices |
account_ | A child account connects to a platform | Onboard the account |
transfer.sent / transfer.received | A p2p transfer is sent / received | Reconcile |
withdrawal.create / withdrawal.update | A bank withdrawal is created / changes status | Track payouts |
swap.create / swap.update | A stablecoin send is created / changes status | Track transfers |
The guide also lists:
- Legacy per-method payment events:
payment_intent.*,bank_transfer_intent.*,automated_,bank_ transfer_ intent.succeeded balance_intent.*,crypto_intent.*andcash_intent.succeeded. - More transfer states:
transfer.pending(automatic review),transfer.in_review(human review) andtransfer.cancelled(rejected; the money returns to the sender). - Partner commissions:
partner_andcommission_ invoice.issued partner_, for the daily commission tax document.commission_ invoice.failed
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_.
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.