Moosyl logo

Webhooks

Real-time payment notifications with HMAC-SHA256 security

Webhooks

Moosyl webhooks deliver real-time notifications for payment events. Every webhook is secured with HMAC-SHA256 signatures to ensure authenticity and integrity.

Quick Reference

Request Headers

HeaderDescription
x-webhook-signatureHMAC-SHA256 signature (sha256=<hex>)
x-webhook-eventEvent type that triggered the webhook
content-typeAlways application/json
user-agentMoosyl-Webhook/1.0

Request Format

Webhook calls send a JSON body with this envelope:

{
  "event": "payment-created",
  "data": {
    "...": "payment object"
  }
}

The event in the body always matches x-webhook-event in request headers.

Webhook Event Reference

This section explains what each webhook means, when it is sent, and how to handle it.

EventWhen It's SentWhat To Do
payment-request-createdA new payment request is createdStore request details and mark it as awaiting payment
payment-request-updatedRequest-level status or details changeSync request state in your system
payment-createdA payment attempt is created for a requestRecord payment attempt and start tracking lifecycle
payment-updatedPayment status changes (for example pending -> completed)Update order/fulfillment state based on final payment status

Event Details

payment-request-created

  • Trigger: when a payment request is first created.
  • Typical use: create a local record and associate it with your order/workflow.
  • Key fields to store: data.id, data.transactionId, data.amount, data.status, data.createdAt.

payment-request-updated

  • Trigger: when a payment request changes state or metadata.
  • Typical use: keep your internal request status in sync.
  • Key fields to monitor: data.status, data.updatedAt.

payment-created

  • Trigger: when a payment object is created for a request.
  • Typical use: log the attempt and start reconciliation/monitoring.
  • Key fields to store: data.id, data.paymentRequestId, data.status, data.metadata, data.transactionId.

payment-updated

  • Trigger: when the payment status changes.
  • Typical use: drive business logic (confirm order, release goods, notify customer).
  • Key fields to monitor: data.status, data.referenceId, data.completedAt, data.updatedAt.

Event Payload Examples

payment-created

{
  "event": "payment-created",
  "data": {
    "id": "2fd1ef95-0af6-4adf-a860-bd4d161fbc5f",
    "amount": 1500,
    "phoneNumber": "22222222",
    "passCode": null,
    "status": "pending",
    "environmentId": "a3f79c2f-bd4f-45d9-92f8-5f8e9095f04a",
    "paymentRequestId": "f57a2cc2-2ab1-4f5c-98ef-c38eb41257e8",
    "configurationId": "17ce17cb-b657-4def-b754-1ef633fd5f18",
    "referenceId": null,
    "metadata": {
      "provider": "bankily",
      "paymentCode": "1234567"
    },
    "payoutId": null,
    "completedAt": null,
    "createdAt": "2026-02-16T10:20:31.000Z",
    "updatedAt": "2026-02-16T10:20:31.000Z",
    "request": {
      "id": "f57a2cc2-2ab1-4f5c-98ef-c38eb41257e8",
      "transactionId": "INV-100231",
      "amount": 1500,
      "totalAmount": 1500,
      "phoneNumber": "22222222",
      "status": "pending",
      "environmentId": "a3f79c2f-bd4f-45d9-92f8-5f8e9095f04a",
      "createdAt": "2026-02-16T10:20:30.000Z",
      "updatedAt": "2026-02-16T10:20:30.000Z"
    },
    "transactionId": "INV-100231"
  }
}

payment-updated

{
  "event": "payment-updated",
  "data": {
    "id": "2fd1ef95-0af6-4adf-a860-bd4d161fbc5f",
    "amount": 1500,
    "phoneNumber": "22222222",
    "passCode": null,
    "status": "completed",
    "environmentId": "a3f79c2f-bd4f-45d9-92f8-5f8e9095f04a",
    "paymentRequestId": "f57a2cc2-2ab1-4f5c-98ef-c38eb41257e8",
    "configurationId": "17ce17cb-b657-4def-b754-1ef633fd5f18",
    "referenceId": "BK-REF-884421",
    "metadata": {
      "provider": "bankily",
      "paymentCode": "1234567"
    },
    "payoutId": null,
    "completedAt": "2026-02-16T10:21:02.000Z",
    "createdAt": "2026-02-16T10:20:31.000Z",
    "updatedAt": "2026-02-16T10:21:02.000Z",
    "request": {
      "id": "f57a2cc2-2ab1-4f5c-98ef-c38eb41257e8",
      "transactionId": "INV-100231",
      "amount": 1500,
      "totalAmount": 1500,
      "phoneNumber": "22222222",
      "status": "completed",
      "environmentId": "a3f79c2f-bd4f-45d9-92f8-5f8e9095f04a",
      "createdAt": "2026-02-16T10:20:30.000Z",
      "updatedAt": "2026-02-16T10:21:02.000Z"
    },
    "transactionId": "INV-100231"
  }
}

Signature Verification

How It Works

Webhooks are signed using HMAC-SHA256 with your webhook secret as the key and the raw request body as the message.

Verification Steps

const crypto = require('crypto');

function verifyWebhookSignature(payload, signature, secret) {
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(payload, 'utf8')
    .digest('hex');

  const receivedHex = signature.slice(7); // Remove 'sha256=' prefix

  return crypto.timingSafeEqual(
    Buffer.from(receivedHex, 'hex'),
    Buffer.from(expectedSignature, 'hex')
  );
}

Security Requirements

  • Always verify signatures before processing
  • Use timing-safe comparison to prevent timing attacks
  • Validate event types against allowed list
  • Store secrets securely (never in client-side code)

Using moosyl-sdk

If you use moosyl-sdk, you can verify signatures and construct typed events directly:

import { Moosyl, WebhookSignatureError } from 'moosyl-sdk';

const moosyl = new Moosyl(process.env.MOOSYL_PUBLISHABLE_KEY);

app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-webhook-signature'];

  try {
    const { event, data } = moosyl.constructWebhookEvent(
      req.body,
      signature,
      process.env.WEBHOOK_SECRET
    );

    handleWebhookEvent(event, data);
    return res.json({ received: true });
  } catch (error) {
    if (error instanceof WebhookSignatureError) {
      return res.status(401).json({ error: 'Invalid signature' });
    }
    throw error;
  }
});

If you only need boolean verification without parsing, use:

const isValid = moosyl.verifyWebhookSignature(rawBody, signature, webhookSecret);

Implementation

Node.js/Express

app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-webhook-signature'];
  const event = req.headers['x-webhook-event'];

  // Verify signature
  if (!verifyWebhookSignature(req.body, signature, process.env.WEBHOOK_SECRET)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  // Process event
  const payload = JSON.parse(req.body);
  handleWebhookEvent(event, payload.data);

  res.json({ received: true });
});

Python/Flask

@app.route('/webhook', methods=['POST'])
def webhook():
    signature = request.headers.get('x-webhook-signature')
    event = request.headers.get('x-webhook-event')

    # Verify signature
    if not verify_webhook_signature(request.data, signature, os.environ['WEBHOOK_SECRET']):
        return jsonify({'error': 'Invalid signature'}), 401

    # Process event
    payload = json.loads(request.data)
    handle_webhook_event(event, payload['data'])

    return jsonify({'received': True}), 200

PHP

$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$event = $_SERVER['HTTP_X_WEBHOOK_EVENT'] ?? '';
$payload = file_get_contents('php://input');

// Verify signature
if (!verifyWebhookSignature($payload, $signature, $_ENV['WEBHOOK_SECRET'])) {
    http_response_code(401);
    echo json_encode(['error' => 'Invalid signature']);
    exit;
}

// Process event
$data = json_decode($payload, true);
handleWebhookEvent($event, $data['data']);

http_response_code(200);
echo json_encode(['received' => true]);

Configuration

Setting Up Webhooks

  1. Go to dashboard → Webhooks section
  2. Add webhook URL → Your endpoint URL
  3. Select events → Choose which events to receive
  4. Set webhook secret → Generate secure secret for verification

Retry Policy

  • Initial retry: 1 minute
  • Backoff: Exponential (2, 4, 8, 16, 32 minutes)
  • Max attempts: 6 retries
  • Total window: ~1 hour

Testing

Local Development

Use ngrok to expose local server to the internet. Use ngrok URL in dashboard webhook settings.

ngrok http 3000

Testing Tools

  • Webhook.site: Free temporary endpoints
  • RequestBin: Capture and inspect requests
  • Postman: Manual endpoint testing

Common Issues

Signature Verification Fails

Causes:

  • Wrong webhook secret
  • Using parsed body instead of raw payload
  • Incorrect HMAC-SHA256 implementation

Fix:

// Use raw body, not parsed JSON
app.use(express.raw({ type: 'application/json' }));

Webhook Not Received

Check:

  • Endpoint is publicly accessible
  • Returns 200 status code
  • HTTPS enabled
  • Firewall allows POST requests

Duplicate Events

Webhooks may be delivered multiple times due to timeouts, retries, or other network issues. You need to handle duplicates on your own.

Timeout Errors

Fix:

  • Respond within 30 seconds
  • Process heavy operations asynchronously
  • Use background jobs for complex logic

Best Practices

  • Always verify signatures before processing
  • Implement idempotency for duplicate handling
  • Respond quickly (200 status within 30s)
  • Use HTTPS for all webhook endpoints
  • Log all webhook attempts for debugging
  • Monitor delivery success in dashboard
  • Test thoroughly before production deployment

Support

Need help with webhooks?

Webhooks | Moosyl Docs