Webhooks
Terudo webhooks. Events, payload, the Terudo-Signature header and how to verify it in Node.js, Python, PHP and Ruby, retries, testing locally.
Terudo calls your HTTPS endpoint at each step of an order and of its eSIM. Create endpoints in the portal (Développeurs) or with the API:
curl -X POST https://terudo.com/api/partner/v1/webhooks \
-H "Authorization: Bearer sk_live_…" -H "Content-Type: application/json" \
-d '{ "url": "https://www.your-agency.example/hooks/terudo", "events": ["order.paid", "order.delivered", "esim.installed", "esim.low_data"] }'
# → { "id": 3, "object": "webhook_endpoint", "mode": "live", "events": [ … ], "secret": "whsec_…" }
Keep secret: it signs every call. An endpoint belongs to the key's mode: a test key creates test endpoints, which receive the sandbox's events only. events empty means all events. The URL must be public HTTPS on port 443.
Events
| Type | When |
|---|---|
order.paid |
The traveller paid (commission), or your credit was debited (net) |
order.delivered |
The eSIM was sent; the order's esim object is filled |
order.failed |
The supplier did not deliver; Terudo steps in |
order.refunded |
Refunded, by you or by Terudo |
order.cancelled |
Payment link cancelled |
esim.installed |
The profile is installed on a phone |
esim.low_data |
A quarter of the data or less is left |
esim.used_up |
No data left |
esim.expiring |
Validity ends soon |
esim.expired |
Validity over |
esim.deleted |
The traveller removed the profile from the phone |
esim.suspended, esim.resumed |
Data cut, restored |
topup.delivered |
A top-up was added to the eSIM |
Payload
POST /hooks/terudo HTTP/1.1
Content-Type: application/json
User-Agent: Terudo-Webhooks/1
Terudo-Signature: t=1764756000,v1=5f2c…
Terudo-Event: order.delivered
Terudo-Delivery: 9812
{ "id": "evt_8Hq…", "type": "order.delivered", "created_at": "2026-12-01T08:00:00Z", "mode": "live",
"data": { "order": { "id": "6b1f…", "object": "order", "reference": "visa-48213", "status": "delivered", "kind": "commission",
"plan": { "id": "MA-DAY2-8D", … }, "client": { "email": "[email protected]", "locale": "fr" },
"esim": { "id": "k8Zs…", "status": "…", "page_url": "https://terudo.com/esim/k8Zs…/", … }, … } } }
data.order is the same object as GET /orders/{id}, with your reference and metadata. Use id (the event id) to ignore a duplicate: a retried delivery has the same event id.
Verify the signature
Terudo-Signature: t=<unix time>,v1=<hex HMAC-SHA256 of "<t>.<raw body>" with your endpoint secret>. Compute it on the raw body (bytes as received, before any JSON parsing), compare in constant time, and reject a timestamp more than 5 minutes old.
Node.js (Express)
import crypto from 'node:crypto';
import express from 'express';
const app = express();
app.post('/hooks/terudo', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.get('Terudo-Signature') || '';
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const expected = crypto.createHmac('sha256', process.env.TERUDO_WEBHOOK_SECRET).update(`${parts.t}.${req.body}`).digest('hex');
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
const valid = parts.v1 && parts.v1.length === expected.length && crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
if (!fresh || !valid) return res.status(400).send('bad signature');
const event = JSON.parse(req.body);
// handle event.type, event.data.order …
res.sendStatus(200);
});
Python (Flask)
import hashlib, hmac, os, time
from flask import Flask, request, abort
app = Flask(__name__)
@app.post("/hooks/terudo")
def terudo_hook():
parts = dict(p.split("=", 1) for p in request.headers.get("Terudo-Signature", "").split(",") if "=" in p)
body = request.get_data() # raw bytes
expected = hmac.new(os.environ["TERUDO_WEBHOOK_SECRET"].encode(), f"{parts.get('t')}.".encode() + body, hashlib.sha256).hexdigest()
if not parts.get("t") or abs(time.time() - int(parts["t"])) > 300 or not hmac.compare_digest(expected, parts.get("v1", "")):
abort(400)
event = request.get_json()
return "", 200
PHP
$body = file_get_contents('php://input');
parse_str(str_replace(',', '&', $_SERVER['HTTP_TERUDO_SIGNATURE'] ?? ''), $parts);
$expected = hash_hmac('sha256', ($parts['t'] ?? '') . '.' . $body, getenv('TERUDO_WEBHOOK_SECRET'));
if (abs(time() - (int)($parts['t'] ?? 0)) > 300 || !hash_equals($expected, $parts['v1'] ?? '')) { http_response_code(400); exit; }
$event = json_decode($body, true);
http_response_code(200);
Ruby (Rails)
def terudo
body = request.raw_post
parts = request.headers["Terudo-Signature"].to_s.split(",").to_h { |p| p.split("=", 2) }
expected = OpenSSL::HMAC.hexdigest("SHA256", ENV.fetch("TERUDO_WEBHOOK_SECRET"), "#{parts['t']}.#{body}")
fresh = (Time.now.to_i - parts["t"].to_i).abs < 300
return head(:bad_request) unless fresh && ActiveSupport::SecurityUtils.secure_compare(expected, parts["v1"].to_s)
event = JSON.parse(body)
head :ok
end
Answer fast, retry safe
Answer 2xx within 10 seconds, then do the work. Any other answer, or no answer, is retried after 1, 5, 30, 120 and 720 minutes. After 25 failures in a row the endpoint is switched off; turn it back on in the portal or with PATCH /webhooks/{id} { "enabled": true }. GET /webhooks/{id}/deliveries shows the last 100 deliveries with your server's answer; POST /webhooks/{id}/deliveries/{delivery_id}/resend sends one again.
Test locally
POST /webhooks/{id}/testsends a signed test event to the endpoint right away.- With the CLI, sign and send a sample event to your local server:
terudo webhooks trigger order.delivered --url http://localhost:3000/hooks/terudo --secret whsec_…. - With a test key, a sandbox order goes through
order.paidandorder.deliveredlike a real one.