Skip to content

Webhook Events

Zelta Pay sends webhook events to notify your application about important changes. This API reference documents all available event types and their payloads.

Every event is sent to the receiving webhook; there is no per-event subscription, so ignore the types you don’t handle. Payment link events (payment.success, payment.failed, receipt.created) go to the account webhook, or, for links created with a project-scoped API key, to the project webhook first with the account webhook as fallback. Events marked project webhook are sent only to the project’s webhook, with no fallback.

IDs such as paymentLinkId, accountId, projectId, subscriptionId or customer.id are numeric strings with no prefix. eventId is a UUID (see Common Fields).

Sent when a customer successfully completes a payment.

Event Type: payment.success

Payload:

{
"eventId": "9b2f6c1e-3d4a-4f8b-9c2d-1a2b3c4d5e6f",
"type": "payment.success",
"paymentLinkId": "7312345678901234567",
"accountId": "7298765432109876543",
"projectId": null,
"createdAt": "2024-01-15T10:30:00.000Z",
"transaction": {
"externalPaymentId": "ext_abc123",
"orderId": "ORD-12345-xyz",
"amount": 2500,
"currency": "USD",
"paidAt": "2024-01-15T10:30:00.000Z",
"paymentMethod": "credit_card",
"paymentProvider": "nmi",
"concept": "Consulting Service",
"paidTo": "Acme Inc",
"isTest": false
},
"customer": {
"customerName": "John Doe",
"customerEmail": "john@example.com"
},
"metadata": {
"orderId": "ORD-001",
"service": "consulting"
}
}

projectId is always present at the top level: it carries the project ID when the payment link was created via a project-scoped API key, and is null otherwise.

When the customer pays with PayPal, transaction.paymentProvider is "paypal" and:

  • transaction.paymentMethod is "paypal" when the customer paid with a PayPal account, or "credit_card" when they paid with a debit/credit card through PayPal’s guest checkout. Treat it as informative only.
  • transaction.externalPaymentId is the PayPal capture id: the id of the payment you see in your PayPal account and use for refunds.
  • transaction.paidAt is when PayPal completed the capture.
  • customer.customerEmail is the email set on the link, else the one the customer typed on the payment page, else the customer’s PayPal email.
{
"eventId": "9b2f6c1e-3d4a-4f8b-9c2d-1a2b3c4d5e6f",
"type": "payment.success",
"paymentLinkId": "7312345678901234567",
"accountId": "7298765432109876543",
"projectId": null,
"createdAt": "2026-09-28T15:04:12.000Z",
"transaction": {
"externalPaymentId": "3C679366HH908993F",
"orderId": "ORD-12345-xyz",
"amount": 4999,
"currency": "USD",
"paidAt": "2026-09-28T15:04:10.000Z",
"paymentMethod": "paypal",
"paymentProvider": "paypal",
"concept": "Order #12345",
"paidTo": "Acme Inc",
"isTest": false
},
"customer": {
"customerName": "Maria Lopez",
"customerEmail": "maria@example.com"
},
"metadata": {
"orderId": "ORD-12345"
}
}

Some PayPal payments are held by PayPal for review before they complete (from minutes to several days). Meanwhile the link shows the processing status, and payment.success is sent once PayPal completes the payment.

Sent when a payment attempt ends without payment: a card decline or error, a Yappy payment that is cancelled, expired or rejected (failureReason "Payment cancelled", "Payment expired" or "Payment rejected"), or an Emetec checkout that is rejected, errored, expired or has an unknown outcome. For PayPal, only real declines (see below).

Event Type: payment.failed

Payload:

{
"eventId": "9b2f6c1e-3d4a-4f8b-9c2d-1a2b3c4d5e6f",
"type": "payment.failed",
"paymentLinkId": "7312345678901234567",
"accountId": "7298765432109876543",
"projectId": null,
"createdAt": "2024-01-15T10:30:00.000Z",
"transaction": {
"orderId": "ORD-12345-xyz",
"amount": 2500,
"currency": "USD",
"failedAt": "2024-01-15T10:30:00.000Z",
"failureReason": "Duplicate transaction REFID:1010907601",
"failureCode": "duplicate_transaction",
"paymentMethod": "credit_card",
"paymentProvider": "nmi",
"concept": "Consulting Service",
"paidTo": "Acme Inc",
"isTest": false
},
"customer": {
"customerName": "John Doe",
"customerEmail": null
},
"metadata": {
"orderId": "ORD-001"
}
}

The transaction object carries orderId, amount (cents), currency, failedAt, failureReason (the processor’s verbatim text), an optional normalized failureCode, concept, paidTo, paymentMethod, paymentProvider, and isTest. There is no externalPaymentId or paidAt since no payment was completed. customer.customerEmail is null when the customer’s email is unknown.

For PayPal (paymentProvider: "paypal"), payment.failed is sent only for real declines, with failureReason in the form "PayPal: <reason>" (for example "PayPal: INSTRUMENT_DECLINED"). It is not sent when the customer closes the PayPal window, abandons the payment, or the PayPal order expires; the link simply stays payable.

Sent when a PDF receipt has been generated for a completed payment:

  • Payment link payments: paymentLinkId is set; subscriptionId and invoiceId are null. It is dispatched after payment.success, but delivery order is not guaranteed (it can arrive first if payment.success is retried).
  • API charges (POST /v1/charges): no paymentLinkId; subscriptionId and invoiceId are null.
  • Recurring subscription invoice charges: no paymentLinkId; subscriptionId and invoiceId are set, matching the invoice.paid event.

projectId, subscriptionId and invoiceId are always present (null when not applicable). paymentLinkId is omitted when the receipt did not come from a payment link. This event has no top-level createdAt; use receipt.createdAt.

Event Type: receipt.created

Payload:

{
"eventId": "9b2f6c1e-3d4a-4f8b-9c2d-1a2b3c4d5e6f",
"type": "receipt.created",
"paymentTransactionId": "7318888999900001111",
"paymentLinkId": "7312345678901234567",
"accountId": "7298765432109876543",
"projectId": null,
"subscriptionId": null,
"invoiceId": null,
"orderId": "ORD-12345-xyz",
"receipt": {
"fileUrl": "https://cdn-zeltapay.zelta.dev/receipts/7298765432109876543/2024/01/1705314605123_V1StGXR8Z5jdHi6BmyTkq3LpN0aWcF9uE2Ho.pdf",
"originalName": "zeltapay-comprobante-ORD-12345-xyz.pdf",
"mimeType": "application/pdf",
"size": 24576,
"createdAt": "2024-01-15T10:30:05.000Z"
}
}

Sent to the project webhook when a customer saves their card through a collection link (POST /v1/collections/links), or when a customer pays a customer-payment link (POST /v1/collections/payment-links). In the second case it is dispatched after payment.success and before charge.succeeded. The card object carries display-only billing metadata so your software can store it — charges always use the vault reference, never these fields.

Event Type: customer.created

Payload:

{
"eventId": "9b2f6c1e-3d4a-4f8b-9c2d-1a2b3c4d5e6f",
"type": "customer.created",
"accountId": "7298765432109876543",
"projectId": "7301234567890123456",
"createdAt": "2026-06-04T10:30:00.000Z",
"customer": {
"id": "7315555666677778888",
"name": "John Doe",
"email": "john@example.com",
"isTest": false,
"paymentProvider": "nmi",
"card": {
"bin": "411111",
"exp": "1028",
"last4": "1111",
"brand": "visa"
}
},
"metadata": {
"userId": "usr_123"
}
}

Sent to the project webhook when a subscription is created (POST /v1/subscriptions). No charge happens at creation — the first recurring charge runs at currentPeriodEnd (see the billing model). items carries the priced breakdown and cycleTotal is the per-cycle amount in cents.

Event Type: subscription.created

Payload:

{
"eventId": "9b2f6c1e-3d4a-4f8b-9c2d-1a2b3c4d5e6f",
"type": "subscription.created",
"accountId": "7298765432109876543",
"projectId": "7301234567890123456",
"createdAt": "2026-06-04T10:30:00.000Z",
"subscriptionId": "7316666777788889999",
"customerId": "7315555666677778888",
"productId": "111",
"productCode": "pos-base",
"items": [
{ "productCode": "pos-base", "name": "Plan base", "price": 3500, "quantity": 1 },
{ "productCode": "pos-sucursal", "name": "Sucursal adicional", "price": 2000, "quantity": 1 }
],
"cycleTotal": 5500,
"status": "active",
"currentPeriodStart": "2026-06-04T00:00:00.000Z",
"currentPeriodEnd": "2026-07-04T00:00:00.000Z",
"nextBillingAt": "2026-07-04T00:00:00.000Z"
}

Sent to the project webhook when a recurring charge succeeds and its invoice is settled. items mirrors the invoice’s frozen snapshot, so the lines always match the charged amount (cents).

Event Type: invoice.paid

Payload:

{
"eventId": "9b2f6c1e-3d4a-4f8b-9c2d-1a2b3c4d5e6f",
"type": "invoice.paid",
"accountId": "7298765432109876543",
"projectId": "7301234567890123456",
"createdAt": "2026-07-04T00:00:05.000Z",
"subscriptionId": "7316666777788889999",
"invoiceId": "7317777888899990000",
"amount": 5500,
"currency": "USD",
"items": [
{ "productCode": "pos-base", "name": "Plan base", "price": 3500, "quantity": 1 },
{ "productCode": "pos-sucursal", "name": "Sucursal adicional", "price": 2000, "quantity": 1 }
],
"externalChargeId": "ext_456"
}

A subscription charge also emits a charge.succeeded event (carrying the same subscriptionId) so integrations that record charges/receipts off charge.succeeded see recurring billing too.

Sent to the project webhook when a recurring charge is declined. The invoice stays open so dunning can retry the same period. failureReason is the processor’s verbatim text; failureCode is the optional normalized cause (see the failure codes table).

Event Type: invoice.payment_failed

Payload:

{
"eventId": "9b2f6c1e-3d4a-4f8b-9c2d-1a2b3c4d5e6f",
"type": "invoice.payment_failed",
"accountId": "7298765432109876543",
"projectId": "7301234567890123456",
"createdAt": "2026-07-04T00:00:05.000Z",
"subscriptionId": "7316666777788889999",
"invoiceId": "7317777888899990000",
"amount": 5500,
"currency": "USD",
"items": [
{ "productCode": "pos-base", "name": "Plan base", "price": 3500, "quantity": 1 }
],
"failureReason": "Card declined"
}

Sent to the project webhook when a failed renewal schedules a dunning retry. attempts is the number of failed attempts so far; nextBillingAt is when the next retry runs (days +1/+3/+5/+7 from the original due date).

Event Type: subscription.past_due

Payload:

{
"eventId": "9b2f6c1e-3d4a-4f8b-9c2d-1a2b3c4d5e6f",
"type": "subscription.past_due",
"accountId": "7298765432109876543",
"projectId": "7301234567890123456",
"createdAt": "2026-07-04T00:00:05.000Z",
"subscriptionId": "7316666777788889999",
"attempts": 1,
"nextBillingAt": "2026-07-05T00:00:00.000Z"
}

Sent to the project webhook when PATCH /v1/subscriptions/:id/reactivate succeeds, in two cases:

  • A scheduled cancellation is lifted (cancelAtPeriodEnd): the subscription keeps running, and status is its current status (active or past_due).
  • A cancelled subscription is reactivated while still inside its already-paid period: the subscription returns to active (status: "active") and billing re-arms at currentPeriodEnd.

Event Type: subscription.resumed

Payload:

{
"eventId": "9b2f6c1e-3d4a-4f8b-9c2d-1a2b3c4d5e6f",
"type": "subscription.resumed",
"accountId": "7298765432109876543",
"projectId": "7301234567890123456",
"createdAt": "2026-06-20T10:30:00.000Z",
"subscriptionId": "7316666777788889999",
"status": "active"
}

Sent to the project webhook when a subscription is cancelled. reason distinguishes how it ended; attempts is present only for dunning_exhausted. A cancel_at_period_end cancellation does not produce a final charge — the upcoming period is not billed.

Event Type: subscription.cancelled

Payload:

{
"eventId": "9b2f6c1e-3d4a-4f8b-9c2d-1a2b3c4d5e6f",
"type": "subscription.cancelled",
"accountId": "7298765432109876543",
"projectId": "7301234567890123456",
"createdAt": "2026-07-04T00:00:00.000Z",
"subscriptionId": "7316666777788889999",
"reason": "cancel_at_period_end"
}
reasonMeaning
cancelled_by_requestCancelled immediately via PATCH /v1/subscriptions/:id/cancel
cancel_at_period_endScheduled cancellation reached the period boundary (no final charge)
dunning_exhaustedCancelled after the 7-day dunning window was exhausted; includes attempts

Sent to the project webhook when a renewal could not be charged because of a merchant-side misconfiguration (the processor that holds the customer’s card is disabled or removed, or its stored credentials can’t be read) — the charge never reached the gateway. This is not a customer decline: dunning is not started and the subscription’s status is left untouched. The billing slot is deferred past a cooldown and the merchant is expected to fix the processor.

Branch on reason: "provider_misconfigured". code is the internal error code that classified the failure; treat it as diagnostic. It is one of:

codeCause
ERR_NO_ACTIVE_<PROVIDER>_<SANDBOX|PRODUCTION>_PAYMENT_PROVIDERThe processor that holds the customer’s card has no usable credentials in the subscription’s environment: NMI is disabled or removed, or Emetec is removed (a disabled Emetec still charges the cards stored with it). <PROVIDER> is NMI or EMETEC, for example ERR_NO_ACTIVE_NMI_PRODUCTION_PAYMENT_PROVIDER
ERR_CREDENTIALS_DECRYPT_PARSE_FAILEDThe processor’s stored credentials can’t be decrypted or parsed
ERR_INVALID_PAYMENT_PROVIDER, ERR_SANDBOX_PAYMENT_PROVIDER_NOT_FOUND, ERR_PRODUCTION_PAYMENT_PROVIDER_NOT_FOUNDThe processor or its credentials were removed while the charge was loading them

Event Type: subscription.billing_blocked

Payload:

{
"eventId": "9b2f6c1e-3d4a-4f8b-9c2d-1a2b3c4d5e6f",
"type": "subscription.billing_blocked",
"accountId": "7298765432109876543",
"projectId": "7301234567890123456",
"createdAt": "2026-07-04T00:00:05.000Z",
"subscriptionId": "7316666777788889999",
"invoiceId": "7317777888899990000",
"reason": "provider_misconfigured",
"paymentProvider": "nmi",
"code": "ERR_NO_ACTIVE_NMI_PRODUCTION_PAYMENT_PROVIDER"
}

Sent to the project webhook when a subscription’s item set is replaced (PATCH /v1/subscriptions/:id/items). The new composition takes effect at effectiveAt (the next billing cycle); cycleTotal is the new per-cycle amount in cents. subscription.created and the invoice.paid / invoice.payment_failed events carry the same items breakdown.

Event Type: subscription.items_updated

Payload:

{
"eventId": "9b2f6c1e-3d4a-4f8b-9c2d-1a2b3c4d5e6f",
"type": "subscription.items_updated",
"accountId": "7298765432109876543",
"projectId": "7301234567890123456",
"createdAt": "2026-06-04T10:30:00.000Z",
"subscriptionId": "7316666777788889999",
"items": [
{ "productCode": "pos-base", "name": "Plan base", "price": 3500, "quantity": 1 },
{ "productCode": "pos-sucursal", "name": "Sucursal adicional", "price": 2000, "quantity": 2 },
{ "productCode": "pos-usuario", "name": "Usuario adicional", "price": 500, "quantity": 2 }
],
"cycleTotal": 8500,
"effectiveAt": "2026-07-04T00:00:00.000Z"
}

Sent to the project webhook when a subscription is re-pointed to another vaulted customer (PATCH /v1/subscriptions/:id/customer) — i.e. the integrating software switched the active payment method. Future invoices and dunning retries charge the new customer’s vault; nothing is billed at switch time.

Event Type: subscription.customer_updated

Payload:

{
"eventId": "9b2f6c1e-3d4a-4f8b-9c2d-1a2b3c4d5e6f",
"type": "subscription.customer_updated",
"accountId": "7298765432109876543",
"projectId": "7301234567890123456",
"createdAt": "2026-06-04T10:30:00.000Z",
"subscriptionId": "7316666777788889999",
"customerId": "7319999000011112222",
"previousCustomerId": "7315555666677778888"
}

Sent to the project webhook when an API-driven charge (POST /v1/charges) is approved, and also when a customer pays a customer_payment link tied to a product (the resulting payment is recorded as a charge). Addon charges include the subscriptionId they are linked to; independent charges (e.g. credits) have it as null.

Event Type: charge.succeeded

Payload:

{
"eventId": "9b2f6c1e-3d4a-4f8b-9c2d-1a2b3c4d5e6f",
"type": "charge.succeeded",
"accountId": "7298765432109876543",
"projectId": "7301234567890123456",
"createdAt": "2026-06-04T10:30:00.000Z",
"charge": {
"id": "7318888999900001111",
"orderId": "ORD-12345-abc",
"amount": 2000,
"currency": "USD",
"quantity": 2,
"prorated": false,
"productCode": "prod_credits100",
"customerId": "7315555666677778888",
"subscriptionId": null,
"externalChargeId": "ext_456",
"paidAt": "2026-06-04T10:30:00.000Z",
"isTest": false
},
"metadata": {
"orderId": "order-789"
}
}

Sent to the project webhook when an API-driven charge is declined.

Event Type: charge.failed

Payload:

{
"eventId": "9b2f6c1e-3d4a-4f8b-9c2d-1a2b3c4d5e6f",
"type": "charge.failed",
"accountId": "7298765432109876543",
"projectId": "7301234567890123456",
"createdAt": "2026-06-04T10:30:00.000Z",
"charge": {
"id": "7318888999900001111",
"orderId": "ORD-12345-abc",
"amount": 1000,
"currency": "USD",
"quantity": 2,
"prorated": true,
"productCode": "prod_addon_seats",
"customerId": "7315555666677778888",
"subscriptionId": "7316666777788889999",
"failureReason": "Duplicate transaction REFID:1010907601",
"failureCode": "duplicate_transaction",
"failedAt": "2026-06-04T10:30:00.000Z",
"isTest": false
},
"metadata": {}
}

failureReason is the processor’s verbatim text. When the decline maps to a known cause, the payload also carries a normalized failureCode so your software can react programmatically (e.g. tell the user what actually happened) instead of parsing the text. The same field is included in invoice.payment_failed and payment.failed events. Today only NMI declines are classified into a failureCode; declines from other providers carry failureReason only.

failureCodeMeaning
duplicate_transactionThe gateway’s duplicate-transaction window rejected a same-amount repeat within ~20 minutes. The card was not declined — retry later or treat as a duplicate of the previous attempt.

Ordinary card declines have no failureCode — rely on failureReason for display.

Sent to the project webhook when a charge that returned 202 processing could not be resolved within the reconciliation window (~2 minutes). It is the signal that no further result is coming for this charge: a charge.succeeded / charge.failed will not follow. The payment may or may not have been captured at the gateway, so the charge is flagged for manual review — never treat it as a clean decline.

Event Type: charge.unknown

Payload:

{
"eventId": "9b2f6c1e-3d4a-4f8b-9c2d-1a2b3c4d5e6f",
"type": "charge.unknown",
"accountId": "7298765432109876543",
"projectId": "7301234567890123456",
"createdAt": "2026-06-04T10:30:00.000Z",
"charge": {
"id": "7318888999900001111",
"orderId": "ORD-12345-abc",
"amount": 2000,
"currency": "USD",
"quantity": 2,
"prorated": false,
"productCode": "prod_credits100",
"customerId": "7315555666677778888",
"subscriptionId": null,
"failureReason": "Gateway outcome unknown after the reconciliation window; manual review required",
"isTest": false
},
"metadata": {}
}

On receiving charge.unknown, stop waiting for a verdict on this orderId / transactionId. Do not blindly retry the same idempotencyKey (the original charge may have actually settled); reconcile against the gateway / contact support, or surface the charge for manual handling. The charge also shows as unknown on GET /v1/charges/:id.

Sent to the project webhook when a POST /v1/charges on NMI comes to less than 100 cents (typically a prorated addon near the end of a period) for an addon that is an item of the customer’s active subscription. NMI cannot charge under $1.00, so the charge is approved without moving money and the amount is billed on the subscription’s next invoice: deferredQuantity (amount / price, possibly fractional) is added to that item’s billed quantity.

The charge object is the same as in charge.succeeded but without externalChargeId and paidAt, plus reason (a Spanish explanation) and deferredQuantity. metadata is included only when the charge request carried it. The POST /v1/charges response is 201 with status: "deferred".

Event Type: charge.deferred

Payload:

{
"eventId": "9b2f6c1e-3d4a-4f8b-9c2d-1a2b3c4d5e6f",
"type": "charge.deferred",
"accountId": "7298765432109876543",
"projectId": "7301234567890123456",
"createdAt": "2026-06-28T10:30:00.000Z",
"charge": {
"id": "7318888999900001111",
"orderId": "ORD-12345-abc",
"amount": 80,
"currency": "USD",
"quantity": 1,
"prorated": true,
"productCode": "pos-usuario",
"customerId": "7315555666677778888",
"subscriptionId": "7316666777788889999",
"reason": "El monto (0.80 USD) es menor al mínimo de NMI (1.00); se aprobó sin cobrar y se difirió al próximo cobro de la suscripción.",
"deferredQuantity": 0.16,
"isTest": false
}
}

Sent to the project webhook only (no fallback to the account webhook) when a payment link requested with a project-scoped API key (POST /v1/payment-links or POST /v1/collections/payment-links) is refused because amount is under 100 cents. No link is created; the request itself also fails with 400 ERR_AMOUNT_BELOW_NMI_MINIMUM. concept and metadata echo the request (omitted when not sent).

Event Type: payment_link.rejected

Payload:

{
"eventId": "9b2f6c1e-3d4a-4f8b-9c2d-1a2b3c4d5e6f",
"type": "payment_link.rejected",
"accountId": "7298765432109876543",
"projectId": "7301234567890123456",
"createdAt": "2026-06-04T10:30:00.000Z",
"reason": "El monto mínimo para un link de pago es 1.00 USD (100 centavos); no se puede generar el link de pago",
"code": "ERR_AMOUNT_BELOW_NMI_MINIMUM",
"amount": 50,
"currency": "USD",
"concept": "Recarga de créditos",
"metadata": {
"orderId": "order-789"
}
}

Sent when you click Enviar ping on the dashboard’s Webhooks page. It has sentAt instead of createdAt.

Event Type: webhook.ping

Payload:

{
"eventId": "9b2f6c1e-3d4a-4f8b-9c2d-1a2b3c4d5e6f",
"type": "webhook.ping",
"ok": true,
"message": "Hello from Zeltapay! Your endpoint is reachable.",
"sentAt": "2024-01-15T10:30:00.000Z",
"description": "This is a ping payload sent to verify your webhook endpoint. If you received this with a 2xx status, everything is working."
}

All events include these common fields:

FieldTypeDescription
eventIdstringUnique identifier for this event: a UUID, the same value as the Zeltapay-Event-Id header
typestringEvent type (e.g., “payment.success”, “webhook.ping”)

createdAt (ISO 8601 timestamp when the event was created) is present on every event except webhook.ping, which carries sentAt instead, and receipt.created, which has no top-level timestamp (use receipt.createdAt).

payment.success uses a deterministic eventId (a version 8 UUID derived from the payment transaction), so a re-send for the same payment has the same eventId. Other events get a random UUID (version 4) per dispatch. Automatic delivery retries always keep the eventId.

FieldTypeDescription
paymentLinkIdstringID of the payment link that was completed
accountIdstringID of the account that owns the payment link
projectIdstring|nullProject ID when the link was created via a project-scoped API key, null otherwise
transactionobjectTransaction details
customerobjectCustomer information
metadataobject (optional)Custom metadata from the payment link. Omitted when the link was created without metadata; guard the access (payload.metadata?.orderId)

transaction.orderId is Zelta Pay’s order id for the payment. metadata.orderId only exists if you set it yourself when creating the link.

FieldTypeDescription
externalPaymentIdstringExternal payment processor ID (for PayPal, the PayPal capture id)
orderIdstringOrder identifier
amountintegerAmount in cents
currencystringCurrency code (e.g., “USD”)
paidAtstringISO 8601 timestamp when payment was completed
paymentMethodstringPayment method used: credit_card, yappy or paypal (a card paid through PayPal’s guest checkout is credit_card)
paymentProviderstringPayment provider name: nmi, emetec, yappy or paypal
conceptstringDescription of what was paid for
paidTostringName of the business receiving payment
isTestbooleanWhether this was a test payment
FieldTypeDescription
customerNamestringName of the customer
customerEmailstring|nullEmail address of the customer (null when unknown)

Every webhook request includes these headers:

HeaderDescriptionExample
Content-TypeAlways application/jsonapplication/json
User-AgentZelta Pay webhook identifierzeltapay-webhook/1.0
Zeltapay-Event-IdUnique event identifier (a UUID, same as eventId)9b2f6c1e-3d4a-4f8b-9c2d-1a2b3c4d5e6f
Zeltapay-Event-TypeEvent typepayment.success
Zeltapay-TimestampUnix timestamp1640995200
Zeltapay-SignatureHMAC signaturet=1640995200, v1=abc123...
Zeltapay-Delivery-AttemptAttempt number1

When you receive a payment.success event:

  1. Verify the signature to ensure authenticity
  2. Check for duplicate events using the eventId
  3. Update your database with the payment information
  4. Send confirmation emails to customers
  5. Trigger fulfillment processes for orders
  6. Update order status to “paid”
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['zeltapay-signature'];
const timestamp = req.headers['zeltapay-timestamp'];
const eventId = req.headers['zeltapay-event-id'];
const eventType = req.headers['zeltapay-event-type'];
// Verify signature
if (!verifySignature(req.body.toString(), timestamp, signature)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const payload = JSON.parse(req.body);
// Check for duplicate events
if (processedEvents.has(eventId)) {
console.log(`Duplicate event ignored: ${eventId}`);
return res.status(200).json({ received: true });
}
// Process based on event type
switch (eventType) {
case 'payment.success':
handlePaymentSuccess(payload);
break;
case 'webhook.ping':
console.log('Webhook ping received:', payload.message);
break;
default:
console.log('Unknown event type:', eventType);
}
// Mark event as processed
processedEvents.add(eventId);
res.status(200).json({ received: true });
});
export default {
async fetch(request, env, ctx) {
if (request.method !== 'POST') {
return new Response('Method not allowed', { status: 405 });
}
const url = new URL(request.url);
if (url.pathname !== '/webhook') {
return new Response('Not found', { status: 404 });
}
try {
const signature = request.headers.get('zeltapay-signature');
const timestamp = request.headers.get('zeltapay-timestamp');
const eventId = request.headers.get('zeltapay-event-id');
const eventType = request.headers.get('zeltapay-event-type');
// Get raw body
const rawBody = await request.text();
// Verify signature
if (!await verifySignature(rawBody, timestamp, signature, env.WEBHOOK_SECRET)) {
return new Response(JSON.stringify({ error: 'Invalid signature' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
const payload = JSON.parse(rawBody);
// Check for duplicate events (using KV store for persistence)
const existingEvent = await env.WEBHOOK_EVENTS.get(eventId);
if (existingEvent) {
console.log(`Duplicate event ignored: ${eventId}`);
return new Response(JSON.stringify({ received: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
// Process based on event type
switch (eventType) {
case 'payment.success':
await handlePaymentSuccess(payload);
break;
case 'webhook.ping':
console.log('Webhook ping received:', payload.message);
break;
default:
console.log('Unknown event type:', eventType);
}
// Mark event as processed (store in KV with 24h TTL)
await env.WEBHOOK_EVENTS.put(eventId, 'processed', { expirationTtl: 86400 });
return new Response(JSON.stringify({ received: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
} catch (error) {
console.error('Webhook processing error:', error);
return new Response(JSON.stringify({ error: 'Processing failed' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}
};
async function verifySignature(rawBody, timestamp, signature, secret) {
// Implementation details in signature verification guide
return true; // Placeholder
}
async function handlePaymentSuccess(payload) {
// Process payment success event
console.log('Payment completed:', payload.transaction.amount);
}

Always check for duplicate events using the eventId. A payment.success event has one eventId per payment: if it is delivered again, it carries the same id.

const processedEvents = new Set();
function isEventProcessed(eventId) {
return processedEvents.has(eventId);
}
function markEventProcessed(eventId) {
processedEvents.add(eventId);
}

Handle different event types gracefully:

function handleWebhookEvent(payload) {
try {
switch (payload.type) {
case 'payment.success':
return handlePaymentSuccess(payload);
case 'webhook.ping':
return handleWebhookPing(payload);
default:
console.log('Unknown event type:', payload.type);
return { success: true, message: 'Event type not handled' };
}
} catch (error) {
console.error('Error handling webhook event:', error);
throw error;
}
}

Log all webhook events for debugging:

function logWebhookEvent(eventId, eventType, payload) {
console.log('Webhook event received:', {
eventId,
eventType,
timestamp: new Date().toISOString(),
payload: JSON.stringify(payload, null, 2)
});
}

Store webhook events in your database:

async function storeWebhookEvent(eventId, eventType, payload) {
try {
await db.webhookEvents.create({
eventId,
eventType,
payload: JSON.stringify(payload),
processed: false,
createdAt: new Date()
});
} catch (error) {
console.error('Error storing webhook event:', error);
throw error;
}
}

Event not received

  • Check webhook URL is correct and accessible
  • Verify HTTPS is enabled
  • Check firewall settings
  • Test with webhook ping

Duplicate events

  • Implement idempotency using eventId
  • Check for race conditions
  • Monitor delivery attempts

Event processing errors

  • Check signature verification
  • Validate payload structure
  • Handle missing fields gracefully

Enable debug logging to troubleshoot:

function debugWebhookEvent(req) {
console.log('Webhook debug info:', {
headers: req.headers,
body: req.body.toString(),
timestamp: new Date().toISOString()
});
}