Merchant webhooks
Rupay sends signed lifecycle events so a merchant can fulfill a payment without trusting browser redirects.
Register an endpoint
POST /v1/webhook_endpoints requires an API key with
webhook_endpoints:write scope.
{
"url": "https://merchant.example/api/webhooks/rupay",
"enabled_events": ["checkout.session.completed"]
}
The URL must be HTTPS, publicly resolvable, and must not resolve to loopback, private, link-local, or reserved addresses. Redirect responses are not followed.
Response:
{
"id": "we_example",
"object": "webhook_endpoint",
"url": "https://merchant.example/api/webhooks/rupay",
"enabled_events": ["checkout.session.completed"],
"secret": "whsec_example"
}
Store secret immediately. Endpoints currently use their merchant's signing secret.
Stripe SDK endpoints
When registered through the pinned Stripe SDK contract, an endpoint gets its own
secret, stores api_version: "2026-02-25.clover", and accepts
checkout.session.completed and checkout.session.expired. Its events contain SDK-shaped checkout objects and
also carry Stripe-Signature, compatible with webhooks.constructEventAsync.
Native registration below retains the existing merchant secret and event format.
See SDK compatibility.
Event types
| Event | When sent | Recommended action |
|---|---|---|
checkout.session.completed | A provider-confirmed payment moves the session to paid | Canonical merchant fulfillment event |
invoice.paid | The same confirmed payment is reflected in Rupay's invoice compatibility layer | Use only if migrating an existing invoice integration |
checkout.session.expired | An unfinished checkout reaches its expiry or is manually expired | Release checkout holds; do not fulfill |
payment.failed | A provider reports a failed attempt | Update UX/operations; do not fulfill |
Subscribe to either checkout.session.completed or invoice.paid for
fulfillment, not both, unless your consumer deliberately deduplicates the two
event types.
Subscription events
Catalog subscription checkouts also support subscription.activated,
subscription.payment_failed, and subscription.expired. These contain the
serialized subscription under data.object rather than a checkout session.
Payment events additionally include a nested subscription object for these
checkouts. Durations are manual prepaid periods, not automatic collection.
Expiry events are queued by the authenticated webhook-delivery worker; access
checks must enforce current_period_end even when delivery is delayed.
See Nepse Edge integration for the full contract.
Delivery
POST /api/webhooks/rupay HTTP/1.1
Content-Type: application/json
Rupay-Event-Id: mwe_example
Rupay-Signature: t=1787261400,v1=hex_hmac_sha256
Payload:
{
"id": "mwe_example",
"object": "event",
"type": "checkout.session.completed",
"data": {
"object": {
"id": "cs_example",
"object": "checkout.session",
"client_reference_id": "fyp_01JABCDEF",
"payment_status": "paid",
"status": "paid",
"amount_total": 250000,
"currency": "npr",
"payment_method_type": "esewa",
"payment_provider": "esewa"
}
},
"created": 1787261400,
"livemode": false,
"urls": {
"success_url": "https://merchant.example/success?session=cs_example",
"cancel_url": "https://merchant.example/cancel"
}
}
For payment events, the full serialized Checkout Session is present under data.object.
Verify the signature
Use the exact request bytes before JSON parsing. The signed input is:
<timestamp>.<raw_request_body>
Compute HMAC-SHA256 with the endpoint secret and compare the hexadecimal digest
to v1 with a constant-time comparison. Reject malformed signatures and
timestamps more than five minutes from the server clock.
The runnable Node example is
examples/verify-webhook-signature.mjs.
Process safely
For a completion event:
- verify the signature and timestamp;
- parse JSON only after verification;
- deduplicate
event.idunder a unique constraint; - find the merchant record by
client_reference_id; - compare session ID,
payment_status, amount, and currency with immutable local values; - atomically transition the local payment and write the business ledger;
- return
2xxquickly.
Return 2xx for an already processed valid event. Return non-2xx for a
temporary processing failure so Rupay retries. Quarantine and alert on an
authenticated event that does not match the expected amount/reference.
Retry behavior
Rupay attempts delivery immediately. Failed deliveries use exponential backoff starting at approximately 30 seconds, capped at six hours, for at most 12 attempts. Delivery calls time out after 10 seconds.
Production Rupay must set a random secret of at least 32 characters and invoke the private background worker from a trusted scheduler. It retries merchant webhooks and sends queued payment-receipt emails (including their invoice and receipt attachments):
RUPAY_WEBHOOK_WORKER_SECRET=replace-with-at-least-32-random-characters
curl --fail --request POST \
--header "Authorization: Bearer $RUPAY_WEBHOOK_WORKER_SECRET" \
https://your-rupay-host.example/internal/webhook-deliveries
The dedicated Cloudflare Cron Worker in workers/operations-cron calls the
production rupay.dev endpoint every minute. Configure its
RUPAY_WEBHOOK_WORKER_SECRET secret with the same value as the production Rupay
web Worker, then deploy it with:
node_modules/.bin/wrangler secret put RUPAY_WEBHOOK_WORKER_SECRET \
--config workers/operations-cron/wrangler.jsonc
node_modules/.bin/wrangler deploy \
--config workers/operations-cron/wrangler.jsonc --keep-vars
The Cron Worker is a separate Cloudflare script and has no public route. It
calls the live rupay-web Worker through a service binding. Check its scheduled invocation
logs and the payment_receipt_emails status after deployment. Cloudflare may
take up to 15 minutes to activate a new Cron Trigger. The endpoint remains an
operator endpoint and is not part of the merchant API.
Provider recovery prerequisite
Rupay marks payments paid only after a verified provider result. Stripe
webhooks must have STRIPE_WEBHOOK_SECRET configured, and eSewa production
must use its signed callback plus server-to-server status verification.
Cybersource Unified Checkout has an asynchronous receiver at
POST /webhooks/cybersource. It accepts only the unifiedCheckout product and
uc.orders.transactionresults event. The receiver:
- requires and decrypts the message-level-encrypted JWE payload;
- verifies
v-c-signatureover the exact decrypted payload with HMAC-SHA256 and rejects stale timestamps; - binds the notification to the configured signature key, organization, webhook subscription, product, and event type;
- verifies Rupay's signed checkout reference plus the immutable amount and currency before changing payment state;
- records the provider event and safely resumes reconciliation when Cybersource retries after a partial local failure.
Successful capture/settlement results complete the Checkout Session. Explicit
failure results emit payment.failed. Authorized, pending, processing, and
unknown future statuses remain pending and never trigger merchant fulfillment.
Cybersource receiver configuration
Configure these server-only values:
CYBERSOURCE_WEBHOOK_ID=the_subscription_webhook_id
CYBERSOURCE_WEBHOOK_MLE_KEY_ID=the_registered_certificate_key_id
CYBERSOURCE_WEBHOOK_MLE_PRIVATE_KEY_BASE64=base64_of_the_matching_pem_private_key
CYBERSOURCE_WEBHOOK_SIGNATURE_KEY_ID=the_digital_signature_key_id
CYBERSOURCE_WEBHOOK_SIGNATURE_SECRET=the_base64_digital_signature_key
CYBERSOURCE_WEBHOOK_RECOVERY_ENABLED=false
GET /webhooks/cybersource is the health-check endpoint. It returns 200 when
the receiver keys are present even while recovery remains disabled for buyer
checkout. Use this cutover sequence:
- create a Cybersource digital-signature key and store its key ID and base64-encoded secret;
- generate a separate 2048-bit RSA MLE key pair, register the X.509 public certificate, and store the returned key ID plus the matching private key;
- create a webhook subscription for
unifiedCheckout/uc.orders.transactionresultswithhttps://<rupay-host>/webhooks/cybersourceas both the notification URL and health-check URL; - wait for URL approval and confirm that the subscription is
ACTIVE; - send an encrypted test notification or complete a sandbox card payment, close the buyer page before browser reconciliation, and verify that the provider webhook alone completes the Rupay session and merchant webhook;
- only then set
CYBERSOURCE_WEBHOOK_RECOVERY_ENABLED=true.
In a production Next.js deployment, Rupay exposes Domestic Cards only when all
receiver values are present and the explicit recovery flag is true.
Otherwise Nepal checkout automatically shows Stripe Cards instead; eSewa
remains available for NPR. This lets Fundyatra launch without Cybersource while
keeping the Domestic Cards cutover fail-closed.
The recovery flag is an operator attestation, not an automatic provider-status check. Do not turn it on merely because the route is deployed. Cybersource URL review can take one to two business days, so subscription activation and the browser-closed recovery test remain required production evidence.