# Guide to Recurrente Webhooks: Features and Best Practices

[Recurrente](https://recurrente.com) 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](https://docs.recurrente.com/guias-espanol/comenzar/webhooks) 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_endpoints` |
| 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_removed` | A recurring product is added or removed (`item` is the changed item, `items` the current list) | Change entitlements |
| `subscription.invoice_item_added` | 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_invoice_settings.enabled` | FEL tax invoicing finished activating | Start issuing invoices |
| `account_connection.create` | 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.*` 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](https://docs.recurrente.com/referencia-api/api-reference/webhook-events/intent-succeeded-webhook) 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](https://github.com/hookdeck/webhook-samples/blob/main/providers/recurrente/latest/intent.succeeded.json):

```json
{
  "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:**

```bash
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](/docs/cli) lets you receive them locally:

```bash
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:

```javascript
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:

```javascript
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](/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](/docs/filters) 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](/docs/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](/docs/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](/webhooks/guides/implement-webhook-idempotency) 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](https://hookdeck.com) adds filtering, longer retries and a full delivery log. [Sign up free](https://dashboard.hookdeck.com/signup) and register a Recurrente source from your Sandbox.