Webhooks
Receive events, verify signatures, handle retries.
What we send
An HTTPS POST with a JSON body like this one, and these headers:
X-Mithila-Event— the event type, e.g. ping.X-Mithila-Delivery— the event ID; the same across retries.X-Mithila-Signature: t=<unix time>,v1=<hex>— HMAC-SHA256 of "<t>.<raw body>" with your signing secret, hex-encoded.
{
"id": "7b0b3d52-0f7f-4b8e-9d4f-2a1c6f1e9b10",
"type": "ping",
"created": 1714550400,
"livemode": true,
"data": {
"message": "Hello from Mithila. Your endpoint is reachable."
}
}
Verify the signature
Always verify before trusting a webhook. Compare in constant time and reject timestamps older than 5 minutes.
PHP
<?php
// Verify a Mithila webhook (plain PHP; works in any framework).
$secret = getenv('MITHILA_WEBHOOK_SECRET'); // whsec_…
$payload = file_get_contents('php://input'); // the RAW body — don't re-encode it
$header = $_SERVER['HTTP_X_MITHILA_SIGNATURE'] ?? ''; // "t=1714550400,v1=5f0c…"
$parts = [];
foreach (explode(',', $header) as $kv) {
[$k, $v] = array_pad(explode('=', trim($kv), 2), 2, '');
$parts[$k][] = $v;
}
$t = (int) ($parts['t'][0] ?? 0);
$expected = hash_hmac('sha256', $t . '.' . $payload, $secret);
$fresh = abs(time() - $t) <= 300; // reject replays older than 5 minutes
$match = false;
foreach ($parts['v1'] ?? [] as $sig) {
$match = $match || hash_equals($expected, $sig); // constant-time comparison
}
if (! $fresh || ! $match) {
http_response_code(400);
exit('Invalid signature');
}
$event = json_decode($payload, true);
// The same event can arrive more than once: use $event['id'] to process it only once.
http_response_code(200);
Node.js
// Verify a Mithila webhook (Node.js + Express).
const crypto = require('crypto');
const express = require('express');
const app = express();
// Keep the raw body: the signature covers the exact bytes we sent.
app.post('/webhooks/mithila', express.raw({ type: 'application/json' }), (req, res) => {
const secret = process.env.MITHILA_WEBHOOK_SECRET; // whsec_…
const header = req.get('X-Mithila-Signature') || '';
const parts = Object.fromEntries(header.split(',').map((p) => p.trim().split('=')));
const t = Number(parts.t);
const expected = crypto.createHmac('sha256', secret).update(`${t}.${req.body}`).digest('hex');
const fresh = Math.abs(Date.now() / 1000 - t) <= 300;
const match = typeof parts.v1 === 'string' && parts.v1.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
if (!fresh || !match) return res.status(400).send('Invalid signature');
const event = JSON.parse(req.body);
// Handle event.type; de-duplicate with event.id.
res.sendStatus(200);
});
app.listen(3000);
Retries
Reply with any 2xx status within 10 seconds. Otherwise we retry up to 6 attempts in total: after 1 minute, 5 minutes, 30 minutes, 2 hours and 8 hours. Redirects are not followed. After 50 failures in a row we disable the endpoint and email you.
Events
ping— Sent when you press "Send test event". Use it to check your endpoint.developer.key_created— An API key was created on your account.developer.key_revoked— An API key on your account was revoked.oauth.grant_revoked— Someone disconnected your OAuth app from their account.order.created— You placed an order on the marketplace (sent to the customer's endpoints). No address or phone number in the payload.order.refunded— A refund on one of your orders was processed (sent to the customer's endpoints).seller.order.updated— An order with your store was placed or changed status — confirmed, shipped, delivered, cancelled or returned (sent to the store owner's endpoints). Includes the delivery address only while the order still has to be shipped.