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
| Header | Description |
|---|---|
x-webhook-signature | HMAC-SHA256 signature (sha256=<hex>) |
x-webhook-event | Event type that triggered the webhook |
content-type | Always application/json |
user-agent | Moosyl-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.
| Event | When It's Sent | What To Do |
|---|---|---|
payment-request-created | A new payment request is created | Store request details and mark it as awaiting payment |
payment-request-updated | Request-level status or details change | Sync request state in your system |
payment-created | A payment attempt is created for a request | Record payment attempt and start tracking lifecycle |
payment-updated | Payment 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}), 200PHP
$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
- Go to dashboard → Webhooks section
- Add webhook URL → Your endpoint URL
- Select events → Choose which events to receive
- 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 3000Testing 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?
- Dashboard logs: Check delivery history and errors
- Email support: support@moosyl.com
- FAQ: Common webhook questions
- API docs: Full webhook reference