Guides

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" 200

Endpoints 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

http
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's serial) 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.*.

TypeWhen
zone.createdA zone is live on ns1 and ns2: verified, bought, claimed or added.
zone.deletedA zone was deleted by its owner; ns2 stops answering within seconds.
zone.records_changedRecords changed, from the dashboard, the API, a preset or an import.
zone.delegation_changedNameservers started or stopped pointing at Itoka, or point at it only partly.
order.paidAn order was paid; Itoka is registering, renewing or transferring.
order.completedRegistered or renewed, or the transfer has started.
order.failedThe registry refused it; the money is being returned.
order.cancelledClosed unpaid, or a promo-code order failed and the code was freed.
order.refundedThe money for a failed order went back to the card.
order.creditedThe money for a failed order became Itoka credit.
pingOnly 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.

data
{
  "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

data
{
  "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

data
{ "zone": { "id": "9d467c43-0400-45fb-ac71-2947a142173f", "name": "ojaara.com" } }

order.*

data
{
  "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": "…"
  }
}