Webhooks
Get notified when payments and subscriptions change. Every Moosyl webhook is signed with HMAC-SHA256 so you can verify it came from Moosyl.
Webhooks are how your server learns that a payment went through. Don't rely on the customer's redirect back to your site: treat the webhook as the source of truth and update your order from it.
Set up a webhook
- In the dashboard, open Webhooks in the environment you want (Sandbox or Production).
- Add your endpoint URL, for example
https://example.com/webhooks/moosyl. - Choose the events to receive.
- Enter a webhook secret. You choose it; use a long random string and store it on your server as
MOOSYL_WEBHOOK_SECRET. It's different from your API keys.
Each environment has its own webhooks, so set one up in Sandbox for testing and another in Production.
Events
| Event | Sent when | data |
|---|---|---|
payment-created | A customer submits a payment | Payment |
payment-updated | A pending payment changes status | Payment |
payment-request-created | A payment request is created: by your API call, a checkout session, or a subscription invoice | Payment request |
payment-request-updated | A payment attempt on the request fails and retryCount goes up | Payment request |
subscription-created | A subscription is created | Subscription |
subscription-updated | A subscription changes status (paid, renewed, cancelled, expired) | Subscription |
How payment events arrive depends on the bank app:
- Confirmed immediately (for example Sedad, and every sandbox payment made with a test number): you get a single
payment-createdwhose status is alreadycompletedorfailed. - Confirmed later (for example Masrvi, BCI Pay and Amanty): you get
payment-createdwith statuspending, thenpayment-updatedwhen it completes or fails.
Handle both events the same way: read data.status and act only on completed.
Payment statuses
pending, completed, failed or cancelled. Only completed means you've been paid.
Request format
Moosyl sends a POST request with a JSON body:
{
"event": "payment-created",
"data": { "...": "the payment or subscription" }
}Headers
| Header | Value |
|---|---|
x-webhook-signature | sha256= followed by the hex HMAC-SHA256 of the raw body, keyed with your webhook secret |
x-webhook-event | The event name, e.g. payment-updated |
x-webhook-delivery-id | A unique ID for this delivery. The same ID is reused when a delivery is retried, so use it to skip duplicates. |
content-type | application/json |
user-agent | Moosyl-Webhook/1.0 |
Payment payload
Payment events carry the payment, the payment request it belongs to, and your transactionId:
{
"event": "payment-created",
"data": {
"id": "3f6c1a52-8e1d-4c1f-9d7e-2b0c5f1e9a41",
"transactionId": "order_1043",
"amount": 2500,
"status": "completed",
"phoneNumber": "22222222",
"paymentRequestId": "b7a4e0f2-5c3d-4a8e-9f10-6d2e8c7b1a53",
"configurationId": "9c2d7e41-0b6a-4f3e-8d15-7a9e2c4b6f08",
"environmentId": "5e8b3c17-2f4a-4d6e-b9a0-1c7d3e5f8a24",
"referenceId": "BK-20260925-000123",
"metadata": null,
"payoutId": null,
"completedAt": "2026-09-25T10:15:32.000Z",
"createdAt": "2026-09-25T10:15:30.000Z",
"updatedAt": "2026-09-25T10:15:32.000Z",
"request": {
"id": "b7a4e0f2-5c3d-4a8e-9f10-6d2e8c7b1a53",
"transactionId": "order_1043",
"amount": 2500,
"totalAmount": 2500,
"phoneNumber": null,
"environmentId": "5e8b3c17-2f4a-4d6e-b9a0-1c7d3e5f8a24",
"retryCount": 0,
"createdAt": "2026-09-25T10:14:02.000Z",
"updatedAt": "2026-09-25T10:14:02.000Z"
}
}
}Use transactionId (the ID you chose when creating the payment) to find the order in your system.
Payment request payload
{
"event": "payment-request-created",
"data": {
"id": "5f0c2a9e-8f3b-4a51-9d0e-2c6a7e1b4d93",
"environmentId": "0e8a4c2d-7f19-4b6e-a3d5-9c1f2b7e6a80",
"transactionId": "order_123",
"amount": 1000,
"totalAmount": 1000,
"phoneNumber": null,
"retryCount": 0,
"createdAt": "2026-09-25T10:00:00.000Z",
"updatedAt": "2026-09-25T10:00:00.000Z"
}
}A payment request has no status of its own. Use the payment events to know when it's paid.
Subscription payload
{
"event": "subscription-updated",
"data": {
"id": "d1e7a9c3-4b2f-4e6a-8c05-9f3b7d2e1a64",
"organizationId": "7a3e9c1d-5f2b-4d8e-a6c0-3b9e1f7d2c45",
"customerId": "2c9f4e7a-1d3b-4a6e-8f02-5b7d9c3e1a86",
"priceId": "8e1b5d3f-7a9c-4e2d-b6f0-4c2a8e6d9b17",
"status": "active",
"nextBillingDate": "2026-10-25T00:00:00.000Z",
"startedAt": "2026-09-25T10:14:00.000Z",
"cancelledAt": null,
"expiresAt": null
}
}See Subscriptions for what each status means.
Verify the signature
Always verify x-webhook-signature before trusting a webhook. Compute the HMAC over the raw request body exactly as received. Parsing the JSON and re-serializing it changes the bytes and breaks the signature.
constructWebhookEvent checks the signature and returns a typed { event, data }. It throws WebhookSignatureError if the signature doesn't match.
import express from "express";
import { Moosyl, WebhookSignatureError } from "moosyl-sdk";
const app = express();
// Verifying a webhook doesn't call the API, so any of your keys works here.
const moosyl = new Moosyl(process.env.MOOSYL_SECRET_KEY!);
app.post("/webhooks/moosyl", express.raw({ type: "application/json" }), async (req, res) => {
let webhook;
try {
webhook = moosyl.constructWebhookEvent(
req.body, // raw Buffer
req.header("x-webhook-signature"),
process.env.MOOSYL_WEBHOOK_SECRET!
);
} catch (error) {
if (error instanceof WebhookSignatureError) {
return res.status(401).send("Invalid signature");
}
throw error;
}
if (webhook.event === "payment-created" || webhook.event === "payment-updated") {
if (webhook.data.status === "completed") {
await markOrderPaid(webhook.data.transactionId);
}
}
res.json({ received: true });
});import crypto from "node:crypto";
import express from "express";
const app = express();
function isValidSignature(rawBody, header, secret) {
if (!header?.startsWith("sha256=")) return false;
const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const received = header.slice("sha256=".length);
return (
received.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected))
);
}
app.post("/webhooks/moosyl", express.raw({ type: "application/json" }), async (req, res) => {
const signature = req.header("x-webhook-signature");
if (!isValidSignature(req.body, signature, process.env.MOOSYL_WEBHOOK_SECRET)) {
return res.status(401).send("Invalid signature");
}
const { event, data } = JSON.parse(req.body.toString("utf8"));
if ((event === "payment-created" || event === "payment-updated") && data.status === "completed") {
await markOrderPaid(data.transactionId);
}
res.json({ received: true });
});import hashlib
import hmac
import os
from flask import Flask, abort, request
app = Flask(__name__)
def is_valid_signature(raw_body: bytes, header: str | None, secret: str) -> bool:
if not header or not header.startswith("sha256="):
return False
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(header[len("sha256="):], expected)
@app.post("/webhooks/moosyl")
def moosyl_webhook():
raw_body = request.get_data() # raw bytes, before JSON parsing
if not is_valid_signature(raw_body, request.headers.get("x-webhook-signature"),
os.environ["MOOSYL_WEBHOOK_SECRET"]):
abort(401)
payload = request.get_json()
if payload["event"] in ("payment-created", "payment-updated") \
and payload["data"]["status"] == "completed":
mark_order_paid(payload["data"]["transactionId"])
return {"received": True}<?php
$rawBody = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$secret = getenv('MOOSYL_WEBHOOK_SECRET');
$expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);
if (!hash_equals($expected, $header)) {
http_response_code(401);
exit('Invalid signature');
}
$payload = json_decode($rawBody, true);
if (in_array($payload['event'], ['payment-created', 'payment-updated'], true)
&& $payload['data']['status'] === 'completed') {
markOrderPaid($payload['data']['transactionId']);
}
http_response_code(200);
echo json_encode(['received' => true]);Respond quickly
Return a 2xx status as soon as you've verified and recorded the event. Moosyl waits up to 30 seconds for a response; do slow work (emails, fulfilment) after responding or in a background job.
Retries
If a delivery fails, Moosyl retries it up to 6 times, after 1, 2, 4, 8, 16 and 32 minutes (7 attempts in total). A delivery is retried when:
- your server can't be reached or times out, or
- it responds with
408,429or any5xx.
Any other 4xx response (for example 400 or 401) marks the delivery as failed straight away, without retries.
Because a delivery can be retried, your handler may see the same event more than once. Make it idempotent: store x-webhook-delivery-id and skip deliveries you've already processed, and don't mark an order paid twice.
Test locally
Moosyl needs a public HTTPS URL to reach your server. While developing, expose your local server with a tunnel such as ngrok or Cloudflare Tunnel and use that URL for a webhook in your Sandbox environment. Then make a payment with one of the test phone numbers to trigger events.
Troubleshooting
- Signature never matches. You're probably hashing a parsed body. Hash the raw bytes, use the webhook secret (not an API key), and compare against the value after
sha256=. - No webhooks arrive. Check that the webhook is in the same environment as the keys you're using, that the event is selected, and that the URL is publicly reachable over HTTPS.
- Order marked paid for a failed payment. Check
data.status === "completed"before fulfilling;payment-createdis also sent for failed and pending payments.