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).
Event Types
Section titled “Event Types”Payment Success Event
Section titled “Payment Success Event”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.
PayPal payments
Section titled “PayPal payments”When the customer pays with PayPal, transaction.paymentProvider is "paypal" and:
transaction.paymentMethodis"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.externalPaymentIdis the PayPal capture id: the id of the payment you see in your PayPal account and use for refunds.transaction.paidAtis when PayPal completed the capture.customer.customerEmailis 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.
Payment Failed Event
Section titled “Payment Failed Event”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.
Receipt Created Event
Section titled “Receipt Created Event”Sent when a PDF receipt has been generated for a completed payment:
- Payment link payments:
paymentLinkIdis set;subscriptionIdandinvoiceIdarenull. It is dispatched afterpayment.success, but delivery order is not guaranteed (it can arrive first ifpayment.successis retried). - API charges (
POST /v1/charges): nopaymentLinkId;subscriptionIdandinvoiceIdarenull. - Recurring subscription invoice charges: no
paymentLinkId;subscriptionIdandinvoiceIdare set, matching theinvoice.paidevent.
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" }}Customer Created Event
Section titled “Customer Created Event”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" }}Subscription Created Event
Section titled “Subscription Created Event”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"}Invoice Paid Event
Section titled “Invoice Paid Event”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.
Invoice Payment Failed Event
Section titled “Invoice Payment Failed Event”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"}Subscription Past Due Event
Section titled “Subscription Past Due Event”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"}Subscription Resumed Event
Section titled “Subscription Resumed Event”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, andstatusis its current status (activeorpast_due). - A
cancelledsubscription is reactivated while still inside its already-paid period: the subscription returns toactive(status: "active") and billing re-arms atcurrentPeriodEnd.
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"}Subscription Cancelled Event
Section titled “Subscription Cancelled Event”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"}reason | Meaning |
|---|---|
cancelled_by_request | Cancelled immediately via PATCH /v1/subscriptions/:id/cancel |
cancel_at_period_end | Scheduled cancellation reached the period boundary (no final charge) |
dunning_exhausted | Cancelled after the 7-day dunning window was exhausted; includes attempts |
Subscription Billing Blocked Event
Section titled “Subscription Billing Blocked Event”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:
code | Cause |
|---|---|
ERR_NO_ACTIVE_<PROVIDER>_<SANDBOX|PRODUCTION>_PAYMENT_PROVIDER | The 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_FAILED | The 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_FOUND | The 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"}Subscription Items Updated Event
Section titled “Subscription Items Updated Event”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"}Subscription Customer Updated Event
Section titled “Subscription Customer Updated Event”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"}Charge Succeeded Event
Section titled “Charge Succeeded Event”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" }}Charge Failed Event
Section titled “Charge Failed Event”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.
failureCode | Meaning |
|---|---|
duplicate_transaction | The 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.
Charge Unknown Event
Section titled “Charge Unknown Event”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.
Charge Deferred Event
Section titled “Charge Deferred Event”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 }}Payment Link Rejected Event
Section titled “Payment Link Rejected Event”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" }}Webhook Ping Event
Section titled “Webhook Ping Event”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."}Event Fields
Section titled “Event Fields”Common Fields
Section titled “Common Fields”All events include these common fields:
| Field | Type | Description |
|---|---|---|
eventId | string | Unique identifier for this event: a UUID, the same value as the Zeltapay-Event-Id header |
type | string | Event 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.
Payment Success Event Fields
Section titled “Payment Success Event Fields”| Field | Type | Description |
|---|---|---|
paymentLinkId | string | ID of the payment link that was completed |
accountId | string | ID of the account that owns the payment link |
projectId | string|null | Project ID when the link was created via a project-scoped API key, null otherwise |
transaction | object | Transaction details |
customer | object | Customer information |
metadata | object (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.
Transaction Object
Section titled “Transaction Object”| Field | Type | Description |
|---|---|---|
externalPaymentId | string | External payment processor ID (for PayPal, the PayPal capture id) |
orderId | string | Order identifier |
amount | integer | Amount in cents |
currency | string | Currency code (e.g., “USD”) |
paidAt | string | ISO 8601 timestamp when payment was completed |
paymentMethod | string | Payment method used: credit_card, yappy or paypal (a card paid through PayPal’s guest checkout is credit_card) |
paymentProvider | string | Payment provider name: nmi, emetec, yappy or paypal |
concept | string | Description of what was paid for |
paidTo | string | Name of the business receiving payment |
isTest | boolean | Whether this was a test payment |
Customer Object
Section titled “Customer Object”| Field | Type | Description |
|---|---|---|
customerName | string | Name of the customer |
customerEmail | string|null | Email address of the customer (null when unknown) |
Event Headers
Section titled “Event Headers”Every webhook request includes these headers:
| Header | Description | Example |
|---|---|---|
Content-Type | Always application/json | application/json |
User-Agent | Zelta Pay webhook identifier | zeltapay-webhook/1.0 |
Zeltapay-Event-Id | Unique event identifier (a UUID, same as eventId) | 9b2f6c1e-3d4a-4f8b-9c2d-1a2b3c4d5e6f |
Zeltapay-Event-Type | Event type | payment.success |
Zeltapay-Timestamp | Unix timestamp | 1640995200 |
Zeltapay-Signature | HMAC signature | t=1640995200, v1=abc123... |
Zeltapay-Delivery-Attempt | Attempt number | 1 |
Event Processing
Section titled “Event Processing”Handling Payment Success Events
Section titled “Handling Payment Success Events”When you receive a payment.success event:
- Verify the signature to ensure authenticity
- Check for duplicate events using the
eventId - Update your database with the payment information
- Send confirmation emails to customers
- Trigger fulfillment processes for orders
- Update order status to “paid”
Example Handlers
Section titled “Example Handlers”Node.js Handler
Section titled “Node.js Handler”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 });});Cloudflare Workers Handler
Section titled “Cloudflare Workers Handler”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);}Best Practices
Section titled “Best Practices”1. Idempotency
Section titled “1. Idempotency”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);}2. Error Handling
Section titled “2. Error Handling”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; }}3. Logging
Section titled “3. Logging”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) });}4. Database Integration
Section titled “4. Database Integration”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; }}Troubleshooting
Section titled “Troubleshooting”Common Issues
Section titled “Common Issues”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
Debug Mode
Section titled “Debug Mode”Enable debug logging to troubleshoot:
function debugWebhookEvent(req) { console.log('Webhook debug info:', { headers: req.headers, body: req.body.toString(), timestamp: new Date().toISOString() });}Related Documentation
Section titled “Related Documentation”- Webhooks Guide - Complete webhook setup guide
- Signature Verification Guide - Complete signature verification implementation
- Webhook Delivery Guide - Delivery mechanisms and retry logic
- Webhook Idempotency Guide - Prevent duplicate processing
- Use Cases - See webhooks in action