For AI agents: a documentation index is available at the root level at /llms.txt and /llms-full.txt. Append /llms.txt to any URL for a page-level index, or .md for the markdown version of any page.
Guías (Español)Guides (English)Referencia API
  • Getting Started
    • Introduction
    • Authentication
    • Pagination
    • Errors
    • Webhooks
  • Guides
    • Connected Accounts
    • Embedded Checkouts
    • Terminal Sessions
    • Update Subscription Card
    • Subscription Billing Options
LogoLogo
On this page
  • Setting Up Webhooks
  • Via Dashboard
  • Via API
  • Test Mode (Sandbox)
  • Webhook Event Types
  • payment_intent.succeeded
  • payment_intent.failed
  • subscription.create
  • subscription.past_due
  • subscription.paused
  • subscription.cancel
  • bank_transfer_intent.pending
  • bank_transfer_intent.succeeded
  • bank_transfer_intent.failed
  • setup_intent.succeeded
  • setup_intent.cancelled
  • balance_intent.succeeded
  • balance_intent.paid
  • Webhook Payload
  • Best Practices
  • Retries
  • Verifying Webhooks (Security)
  • Signing Secret
  • Verifying with Official Libraries (Recommended)
  • Verifying Manually
Getting Started

Webhooks

Was this page helpful?
Previous

Connected Accounts

Next
Built with

Webhooks notify your application in real-time when events happen — payments, subscription changes, refunds, and more.

Setting Up Webhooks

Via Dashboard

To activate webhooks for your account, within your Recurrente account, go to:

Settings → Developers and API.

There click on “Webhooks”, and continue to add the endpoint where you want the requests to be sent.

Via API

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

Test Mode (Sandbox)

Webhooks are not sent for checkouts created with test keys (pk_test_* / sk_test_*). In test mode, payments with the 4242 4242 4242 4242 card are approved directly without going through the full processing flow, so no webhook events are generated.

To test your webhook integration end-to-end, you need to use production keys (pk_live_* / sk_live_*) with a real card.

Webhook Event Types

Recurrente sends webhooks for the following events:

payment_intent.succeeded

Emitted with a successful card charge (credit or debit). The funds are already in your Recurrente balance.

For in-person payments (POS, mobile POS, and stablecoins), this webhook is sent after the cashier completes the customer data collection step (NIT, phone, email), so that the payload includes the full customer information. This typically adds a few seconds of delay after the payment itself.

Example response:

1{
2 "id": "pa_id123",
3 "event_type": "payment_intent.succeeded",
4 "api_version": "2024-04-24",
5 "checkout": {
6 "id": "ch_id123",
7 "status": "paid",
8 "payment": {
9 "id": "pa_laybj3zw",
10 "paymentable": {
11 "type": "OneTimePayment",
12 "id": "on_arognqni",
13 "tax_name": null,
14 "tax_id": "12345678",
15 "address": null,
16 "phone_number": "+50255551234"
17 }
18 },
19 "payment_method": {
20 "id": "pay_m_7v5ie3pw",
21 "type": "card",
22 "card": {
23 "last4": "4242",
24 "network": "visa"
25 }
26 },
27 "transfer_setups": [],
28 "metadata": {}
29 },
30 "created_at": "2024-02-16T03:01:13.260Z",
31 "failure_reason": null,
32 "amount_in_cents": 10000,
33 "currency": "GTQ",
34 "fee": 450,
35 "vat_withheld": 160,
36 "vat_withheld_currency": "GTQ",
37 "customer": {
38 "email": "hello@example.com",
39 "full_name": "Max Rodriguez",
40 "id": "us_id123",
41 "nit": "12345678",
42 "phone": "+50255551234"
43 },
44 "product": {
45 "id": "prod_id123"
46 },
47 "invoice": {
48 "id": "inv_123",
49 "tax_invoice_url": null
50 }
51}

payment_intent.failed

Failed card charge.

Example response:

1{
2 "id": "pa_id123",
3 "event_type": "payment_intent.failed",
4 "api_version": "2024-03-13",
5 "checkout": {
6 "id": "ch_id123"
7 },
8 "created_at": "2024-02-16T03:01:13.260Z",
9 "failure_reason": "Your bank has rejected the transaction. Call your bank and ask them to authorize this transaction.",
10 "amount_in_cents": 10000,
11 "currency": "GTQ",
12 "fee": 0,
13 "vat_withheld": 0,
14 "vat_withheld_currency": "GTQ",
15 "customer": {
16 "email": "hello@example.com",
17 "full_name": "Max Rodriguez",
18 "id": "us_id123"
19 },
20 "product": {
21 "id": "prod_id123"
22 }
23}

subscription.create

If the product is recurring, this event is emitted in addition to payment.succeeded with the subscription information.

Example response:

1{
2 "api_version": "2024-04-24",
3 "created_at": "2025-10-13T13:59:27.931Z",
4 "customer_email": "example@example.com",
5 "customer_id": "us_1234",
6 "customer_name": "Pedro Pérez",
7 "event_type": "subscription.create",
8 "id": "su_123",
9 "payment": {
10 "id": "pa_123",
11 "paymentable": {
12 "address": null,
13 "id": "su_123",
14 "phone_number": "+50255555555",
15 "tax_id": "",
16 "tax_name": null,
17 "type": "Subscription"
18 }
19 },
20 "product": {
21 "address_requirement": "none",
22 "billing_info_requirement": "optional",
23 "cancel_url": "",
24 "custom_terms_and_conditions": "Terms and conditions",
25 "description": "Test subscription",
26 "has_dynamic_pricing": false,
27 "id": "prod_123",
28 "metadata": {},
29 "name": "Test Plan",
30 "phone_requirement": "required",
31 "prices": [
32 {
33 "amount_in_cents": 999,
34 "billing_interval": "month",
35 "billing_interval_count": 1,
36 "charge_type": "recurring",
37 "currency": "GTQ",
38 "free_trial_interval": "month",
39 "free_trial_interval_count": 0,
40 "id": "price_123",
41 "periods_before_automatic_cancellation": null
42 }
43 ],
44 "status": "active",
45 "storefront_link": "https://app.recurrente.com/s/recurrente/test-plan",
46 "success_url": ""
47 }
48}

subscription.past_due

Emitted when a subscription’s automatic charge fails for the first time.

Note: In a subscription, when a payment fails, Recurrente attempts to charge it again 3 and 5 days later. If both retry attempts fail, the subscription is canceled at that time.

subscription.paused

Emitted when a subscription is paused. A paused subscription will not be charged again until it is reactivated.

subscription.cancel

Emitted when a subscription’s automatic charge fails for the third time.

Note: In a subscription, when a payment fails, Recurrente attempts to charge it again 3 and 5 days later. If both retry attempts fail, the subscription is canceled at that time.

bank_transfer_intent.pending

Emitted when a bank transfer charge is initiated. As soon as the money is received in the account, bank_transfer_intent.succeeded will be emitted. Otherwise, bank_transfer_intent.failed will be emitted.

bank_transfer_intent.succeeded

Emitted with a successful bank transfer charge. The funds are already in your Recurrente balance.

bank_transfer_intent.failed

Emitted with a failed bank transfer charge. This happens when the funds are not received in the bank account, or the wrong amount is received.

setup_intent.succeeded

Emitted when a subscription with a trial period is successfully initiated. Also emitted when a card is tokenized without charging it.

setup_intent.cancelled

Emitted when a card cannot be tokenized without charging it. This happens when the first payment of a subscription with a trial period fails.

balance_intent.succeeded

Emitted with a successful charge paid using the customer’s Recurrente balance. The funds are already in your Recurrente balance. This event is delivered to the merchant account (the account that received the payment).

balance_intent.paid

Emitted to the payer account (the account whose balance was debited) when it successfully pays a checkout using its Recurrente balance. You only receive it if the payer account has a webhook endpoint configured.

Note: a single balance-paid charge triggers two independent webhook deliveries: balance_intent.succeeded to the merchant and balance_intent.paid to the payer. The payload follows the same shape as bank_transfer_intent.succeeded (includes checkout, payment, customer, amount_in_cents, currency, product, tax_invoice_url).

Webhook Payload

Each webhook POST includes a JSON body with the event data. The structure varies by event type.

Best Practices

  • Return 2xx quickly — Acknowledge receipt before doing heavy processing
  • Handle duplicates — Webhooks may be delivered more than once; use the event ID for idempotency
  • Verify the source — Validate webhook signatures as described in Verifying Webhooks
  • Process asynchronously — Use a queue for heavy processing after acknowledging receipt

Retries

If your endpoint doesn’t respond with a 2xx code, Recurrente will retry sending the webhook:

  • Immediately after the first failure
  • 1 minute later
  • 5 minutes later
  • 30 minutes later
  • 2 hours later
  • 6 hours later

Verifying Webhooks (Security)

Every webhook sent by Recurrente includes a signature so you can verify it’s authentic and hasn’t been tampered with. We strongly recommend verifying signatures in production.

Signing Secret

Each webhook endpoint has a unique signing secret. You can find it in the Svix dashboard — accessible from Settings → Developers and API → Webhooks in your Recurrente account. The secret looks like whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw.

Verifying with Official Libraries (Recommended)

The easiest way to verify signatures is using the Svix webhook verification libraries, available for many languages:

1// npm install svix
2const { Webhook } = require("svix");
3
4const wh = new Webhook(secret);
5// Throws on error, returns the verified content on success
6const payload = wh.verify(rawBody, headers);

You must use the raw request body when verifying webhooks. Parsing the JSON and re-serializing it will break the signature verification.

Verifying Manually

Each webhook request includes three headers:

HeaderDescription
svix-idUnique message identifier (stays the same on retries)
svix-timestampUnix timestamp (seconds) when the webhook was sent
svix-signatureBase64-encoded signature(s), prefixed with v1,

Step 1. Construct the signed content by concatenating the svix-id, svix-timestamp, and the raw request body, separated by dots:

signed_content = "${svix_id}.${svix_timestamp}.${body}"

Step 2. Decode the base64 portion of your signing secret (everything after whsec_) and use it as the key for an HMAC-SHA256 hash of the signed content:

1const crypto = require("crypto");
2
3const secretBytes = Buffer.from(secret.split("_")[1], "base64");
4const signature = crypto
5 .createHmac("sha256", secretBytes)
6 .update(signedContent)
7 .digest("base64");

Step 3. Compare your computed signature with the one in the svix-signature header. The header value is prefixed with v1, — strip that prefix before comparing. Use a constant-time comparison to prevent timing attacks.

Step 4. Optionally, verify that the svix-timestamp is within a few minutes of your server’s current time to prevent replay attacks.