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}/test sends 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.paid and order.delivered like a real one.