---
title: Webhooks
description: Terudo webhooks. Events, payload, the Terudo-Signature header and how to verify it in Node.js, Python, PHP and Ruby, retries, testing locally.
---

# Webhooks

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:

```bash
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

```http
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": "marie@example.com", "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)**

```js
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)**

```python
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**

```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)**

```ruby
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](https://terudo.com/developers/docs/cli.md), 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.
