Webhooks API
Stripe and MobiMatter webhook endpoints with signature verification for asynchronous payment and eSIM events.
This endpoint does not require JWT authentication. Security is provided by Stripe's signature verification using the
STRIPE_WEBHOOK_SECRETenvironment variable.
MobiMatter callbacks are also public and use MobiMatter's documented RSA-SHA256 signature plus a comparison against the encrypted configured merchant ID.
POST /webhooks/mobimatter
Accepts delayed/KYC order completion callbacks. The handler validates the payload, verifies the signature over orderId.merchantId.providerName, stores a minimal idempotency receipt, and enqueues GET-only reconciliation. It never persists the callback's QR, LPA, or other activation credentials.
Duplicate callbacks return 200. An unknown but valid signed order is acknowledged and retained as a minimal receipt. If the deterministic queue handoff fails, the endpoint returns 503 so MobiMatter can redeliver within its documented retry window.
See MobiMatter eSIM Provider Operations for deployment and recovery details.
POST /webhooks/stripe
Handle Stripe webhook events. Verifies the Stripe-Signature header before processing.
Signature Verification Flow
- Read raw request body as text (required for signature verification — parsed JSON cannot be verified)
- Extract
Stripe-Signatureheader - Call
stripe.webhooks.constructEvent(payload, signature, STRIPE_WEBHOOK_SECRET)— throws on invalid signature - Route the event by type; return
200immediately per T-05-11 (quick acknowledgement) - Payment processing happens asynchronously via
PaymentService
Request Headers
| Header | Required | Description |
|---|---|---|
Stripe-Signature | Yes | Stripe webhook signature for verification |
Request Body
The raw Stripe event payload (JSON). See Stripe Webhook Events for full schema.
Handled Event Types
| Event Type | Action |
|---|---|
payment_intent.succeeded | Mark payment as completed, generate receipt number, enqueue settlement job |
payment_intent.payment_failed | Mark payment as failed with reason from last_payment_error.message |
payment_intent.canceled | Mark payment as failed with reason payment_intent.canceled |
| Other | Acknowledge and ignore |
Settlement verification (payment_intent.succeeded)
The service verifies the provider-reported status, currency, amount, amount_received, and metadata (payment_id, order_id, user_id) against the stored payment row. On mismatch the payment is NOT completed. The payment row is marked failed with reason PAYMENT_PROVIDER_AMOUNT_MISMATCH (only if it is still processing — a completed payment is never downgraded by a late duplicate). A reconciliation record is written to the payment_webhook_events table with the mismatch detail. Both writes share one transaction. The endpoint still returns 200 so the provider stops retrying. If the recording fails, the endpoint returns 500 with code PAYMENT_MISMATCH_RECORD_FAILED so the provider redelivers the event. The mock gateway bypasses verification.
Response
200 OK — Handled event:
{
"received": true
}200 OK — Unhandled event type (ignored):
{
"received": true,
"ignored": true
}Error Responses
| Code | Status | Condition |
|---|---|---|
STRIPE_WEBHOOK_SIGNATURE_MISSING | 400 | Stripe-Signature header not provided |
STRIPE_WEBHOOK_SIGNATURE_INVALID | 400 | Signature verification failed |
Example Webhook Payload
Stripe sends events as JSON with the following structure:
{
"id": "evt_1Rabc2DEF3ghi4JKL",
"object": "event",
"type": "payment_intent.succeeded",
"created": 1746057600,
"data": {
"object": {
"id": "pi_1Mabc2DEF3ghi4JKL",
"object": "payment_intent",
"amount": 4999,
"currency": "sar",
"status": "succeeded",
"metadata": {
"payment_id": "c4d5e6f7-a8b9-0123-cdef-234567890123",
"order_id": "b3c4d5e6-f7a8-9012-bcde-f12345678901",
"user_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479"
}
}
},
"livemode": false,
"pending_webhooks": 1,
"request": {
"id": "req_abc123",
"idempotency_key": null
}
}Payment Failed Example
{
"id": "evt_1Rabc2DEF3ghi4JKL",
"object": "event",
"type": "payment_intent.payment_failed",
"created": 1746057600,
"data": {
"object": {
"id": "pi_1Mabc2DEF3ghi4JKL",
"object": "payment_intent",
"amount": 4999,
"currency": "sar",
"status": "requires_payment_method",
"last_payment_error": {
"message": "Your card was declined.",
"type": "card_error",
"code": "card_declined"
},
"metadata": {
"payment_id": "c4d5e6f7-a8b9-0123-cdef-234567890123",
"order_id": "b3c4d5e6-f7a8-9012-bcde-f12345678901",
"user_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479"
}
}
},
"livemode": false,
"pending_webhooks": 1
}Payment Canceled Example
{
"id": "evt_1Rabc2DEF3ghi4JKL",
"object": "event",
"type": "payment_intent.canceled",
"created": 1746057600,
"data": {
"object": {
"id": "pi_1Mabc2DEF3ghi4JKL",
"object": "payment_intent",
"amount": 4999,
"currency": "sar",
"status": "canceled",
"metadata": {
"payment_id": "c4d5e6f7-a8b9-0123-cdef-234567890123",
"order_id": "b3c4d5e6-f7a8-9012-bcde-f12345678901"
}
}
},
"livemode": false,
"pending_webhooks": 1
}Webhook Configuration
Environment Variables
| Variable | Required | Description |
|---|---|---|
STRIPE_WEBHOOK_SECRET | If PAYMENT_PROVIDER=stripe | Stripe webhook signing secret (whsec_...) |
STRIPE_SECRET_KEY | If PAYMENT_PROVIDER=stripe | Stripe API secret key (sk_test_... or sk_live_...) |
Stripe Dashboard Setup
- Go to Developers → Webhooks in the Stripe Dashboard
- Click Add endpoint
- Set the endpoint URL to
https://<api-production-origin>/webhooks/stripe - Select events:
payment_intent.succeeded,payment_intent.payment_failed,payment_intent.canceled - Copy the Signing secret to
STRIPE_WEBHOOK_SECRET
Local Testing with Stripe CLI
# Install Stripe CLI, then:
stripe listen --forward-to http://localhost:3001/webhooks/stripe
# Trigger a test event:
stripe trigger payment_intent.succeeded