Moosyl logo

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

  1. In the dashboard, open Webhooks in the environment you want (Sandbox or Production).
  2. Add your endpoint URL, for example https://example.com/webhooks/moosyl.
  3. Choose the events to receive.
  4. 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

EventSent whendata
payment-createdA customer submits a paymentPayment
payment-updatedA pending payment changes statusPayment
payment-request-createdA payment request is created: by your API call, a checkout session, or a subscription invoicePayment request
payment-request-updatedA payment attempt on the request fails and retryCount goes upPayment request
subscription-createdA subscription is createdSubscription
subscription-updatedA 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-created whose status is already completed or failed.
  • Confirmed later (for example Masrvi, BCI Pay and Amanty): you get payment-created with status pending, then payment-updated when 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

HeaderValue
x-webhook-signaturesha256= followed by the hex HMAC-SHA256 of the raw body, keyed with your webhook secret
x-webhook-eventThe event name, e.g. payment-updated
x-webhook-delivery-idA unique ID for this delivery. The same ID is reused when a delivery is retried, so use it to skip duplicates.
content-typeapplication/json
user-agentMoosyl-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, 429 or any 5xx.

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-created is also sent for failed and pending payments.