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.

Events & Headers

Supported Events

EventDescription
payment-request-createdNew payment request created
payment-request-updatedPayment request status changed
payment-createdPayment successfully processed
payment-updatedPayment status updated

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

Payload Structure

{
  "event": "payment-created",
  "data": {
    "id": "uuid",
    "amount": 1000,
    "currency": "MRU",
    "status": "completed",
    // ... event-specific fields
  }
}

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)

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