Webhooks
Itoka POSTs a signed JSON event to your server when records change, a domain's nameservers start pointing at Itoka, or an order moves on.
Adding an endpoint
In the dashboard under Webhooks, or with a key that has webhooks:manage for all zones. Keep the secret: it's shown only now, and you need it to check signatures. An account can have 10 endpoints.
const { webhook, secret } = await itoka.webhooks.create({
url: 'https://example.com/itoka/webhooks',
events: ['zone.records_changed', 'order.*'], // leave out for every event
description: 'Deploy bot',
});
const ping = await itoka.webhooks.test(webhook.id); // sends a ping now
console.log(ping.status, ping.last_status); // "succeeded" 200Endpoints must be public https:// URLs. Itoka won't send to private or local addresses and doesn't follow redirects. To receive events on your laptop, use a tunnel (cloudflared tunnel --url http://localhost:3000, or ngrok).
The request
POST /itoka/webhooks HTTP/1.1
Content-Type: application/json
User-Agent: Itoka-Webhooks/1 (+https://itoka.ng)
Itoka-Event-Id: evt_3f9c0e1a2b4d5e6f7a8b9c0d
Itoka-Event-Type: zone.records_changed
Itoka-Signature: t=1792000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
{"id":"evt_3f9c0e1a2b4d5e6f7a8b9c0d","type":"zone.records_changed","created_at":"2026-10-14T16:33:33Z","data":{…}}Answer with any 2xx status within 10 seconds. Anything else, including a redirect or a timeout, counts as a failure.
Checking the signature
Itoka-Signature is t=<unix seconds>,v1=<hex>, where the hex is the HMAC-SHA256 of <t>.<raw body> keyed with your secret. Compute it over the raw bytes you received, before parsing JSON; compare in constant time; and refuse requests more than 5 minutes old.
import { verifyWebhook, WebhookVerificationError } from '@skilledcrow/itoka';
// Express: app.post('/itoka/webhooks', express.raw({ type: 'application/json' }), handler)
// Workers / Next.js route handlers: payload = await request.text()
const event = await verifyWebhook({
payload: req.body, // raw bytes or string
signature: req.get('Itoka-Signature'),
secret: process.env.ITOKA_WEBHOOK_SECRET,
}); // throws WebhookVerificationError if it isn't genuine and recent
switch (event.type) {
case 'zone.records_changed':
for (const c of event.data.changes) console.log(c.name, c.type, c.records);
break;
case 'order.completed':
console.log(event.data.order.name, 'is registered');
}Retries, order and duplicates
- A failed delivery is retried after 1 minute, 5 minutes, 30 minutes, then 2, 5, 10 and 24 hours: 8 attempts over about 42 hours. You can resend any delivery from the dashboard or with
POST /webhooks/{id}/deliveries/{delivery}/redeliver. - If every delivery fails for 3 days, the endpoint is turned off and the account emailed. Turn it back on once it's fixed.
- One endpoint gets its events one at a time, oldest first. A retried event can still arrive after a newer one; use
created_at(or the zone'sserial) when order matters. - The same event can arrive twice (say, after a timeout on your side). Skip
ids you've handled. - Events and their delivery history are kept 30 days.
Events
Zone events go to the zone's owner and every account managing it; order events go to the order's account. Subscribe to all (the default, which includes types added later), to particular types, or to a group like order.*.
| Type | When |
|---|---|
zone.created | A zone is live on ns1 and ns2: verified, bought, claimed or added. |
zone.deleted | A zone was deleted by its owner; ns2 stops answering within seconds. |
zone.records_changed | Records changed, from the dashboard, the API, a preset or an import. |
zone.delegation_changed | Nameservers started or stopped pointing at Itoka, or point at it only partly. |
order.paid | An order was paid; Itoka is registering, renewing or transferring. |
order.completed | Registered or renewed, or the transfer has started. |
order.failed | The registry refused it; the money is being returned. |
order.cancelled | Closed unpaid, or a promo-code order failed and the code was freed. |
order.refunded | The money for a failed order went back to the card. |
order.credited | The money for a failed order became Itoka credit. |
ping | Only from "Send a test event". |
zone.records_changed
Each rrset (name and type) that changed, as it is now. No records means it was deleted.
{
"zone": { "id": "9d467c43-0400-45fb-ac71-2947a142173f", "name": "ojaara.com" },
"serial": 2026101402,
"replicated": true,
"changes": [
{ "name": "www.ojaara.com", "type": "A", "ttl": 300, "records": ["192.0.2.7"] },
{ "name": "old.ojaara.com", "type": "CNAME", "ttl": 0, "records": [] }
]
}zone.delegation_changed
{
"zone": { "id": "9d467c43-0400-45fb-ac71-2947a142173f", "name": "ojaara.ng" },
"state": "live",
"previous_state": "waiting",
"parent_nameservers": ["ns1.itoka.ng", "ns2.itoka.ng"],
"registry_nameservers": ["ns1.itoka.ng", "ns2.itoka.ng"]
}zone.created, zone.deleted
{ "zone": { "id": "9d467c43-0400-45fb-ac71-2947a142173f", "name": "ojaara.com" } }order.*
{
"order": {
"id": "0b5f…", "kind": "register", "name": "ojaara.com", "years": 1, "status": "completed",
"amount_kobo": 1900000, "credit_kobo": 0, "promo_code": "", "promo_kobo": 0, "client_email": "",
"last_error": "", "created_at": "…", "paid_at": "…", "finished_at": "…"
}
}