Documentation
Webhooks
We POST a JSON event to your endpoint when something happens. Delivery is at-least-once, signed, and retried for 24 hours.
Registering an endpoint
POST/v1/webhook-endpoints
Register a URL and the event types it should receive. Returns the signing secret, once.
Requires scope webhooks:manage
GET/v1/webhook-endpoints
List your endpoints with their recent delivery success rate.
Requires scope webhooks:manage
POST/v1/webhook-endpoints/{id}/test
Send a synthetic event of a chosen type, to check your handler end to end.
Requires scope webhooks:manage
DELETE/v1/webhook-endpoints/{id}
Delete an endpoint. Undelivered events for it are dropped.
Requires scope webhooks:manage
curl -X POST https://api.easarctech.com/v1/webhook-endpoints \
-H "Authorization: Bearer esk_live_7f3a9c2e8b14d05a" \
-H "Easarc-Version: 2026-08-01" \
-H "Content-Type: application/json" \
-d '{
"url": "https://yourapp.example/webhooks/easarc",
"events": ["order.stage_advanced", "invoice.registered", "vision.threshold.breached"],
"description": "Production sync worker"
}'The response contains signing_secret, beginning ewhs_. It is shown once. Store it where you store your other secrets, and never in the repository.
Event types
| Type | Fires when |
|---|---|
| order.created | An order was created, by API or in the dashboard |
| order.updated | Any field changed. The payload carries a `changed` array |
| order.stage_advanced | A production stage was completed |
| order.cancelled | An order was cancelled |
| invoice.registered | The IRP returned an IRN and signed QR |
| invoice.failed | IRP registration failed. Payload carries the IRP error codes |
| invoice.cancelled | An IRN was cancelled within the 24-hour window |
| eway_bill.part_b_completed | Vehicle recorded, validity clock started |
| eway_bill.expiring | Fires four hours before an e-way bill expires |
| dispatch.recorded | Goods left the gate |
| dispatch.delivered | Delivery confirmed, with proof of delivery if supplied |
| payment.received | A payment was reconciled against an invoice |
| payment.overdue | An invoice passed its due date unpaid |
| vision.defect.detected | A detection above your confidence threshold |
| vision.threshold.breached | Defect rate crossed your configured limit |
| vision.camera.offline | No frames received for longer than the grace period |
| vision.qc_report.completed | An asynchronous QC report finished generating |
| desk.conversation.escalated | The AI agent handed a conversation to a human |
Payload shape
Every event has the same envelope. data carries the objects relevant to the type, already expanded enough that you rarely need a follow-up request.
{
"id": "evt_01J9ZR3M8T",
"type": "order.stage_advanced",
"api_version": "2026-08-01",
"created_at": "2026-08-08T14:21:07+05:30",
"livemode": true,
"account_id": "acc_01J8S9K2",
"data": {
"order": {
"id": "ord_01J9ZK4M7Q2X8V",
"reference": "ESR-2026-0431",
"status": "in_production",
"stage": "printing"
},
"stage": {
"id": "stg_01J9ZM8K3P",
"stage": "printing",
"previous_stage": "cutting",
"quantity_completed": { "value": 3200, "uom": "MTR" },
"operator_id": "OP-2291",
"machine_id": "PR-04",
"occurred_at": "2026-08-08T14:20:00+05:30"
}
}
}Verifying the signature
Every request carries an Easarc-Signature header. Verify it before you trust the body — an unverified webhook endpoint is an unauthenticated write API pointed at your own database.
import crypto from 'node:crypto'
const TOLERANCE_SECONDS = 300
export function verifyEasarcSignature(rawBody, header, secret) {
// Easarc-Signature: t=1754654466,v1=5257a869e7…
const parts = Object.fromEntries(
header.split(',').map((part) => part.split('='))
)
const timestamp = Number(parts.t)
const signature = parts.v1
if (!timestamp || !signature) return false
// Reject anything old enough to be a replay.
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex')
// Constant time: a fast rejection leaks which byte was wrong.
return crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(signature, 'hex')
)
}Three things that go wrong
- Using the parsed body. The signature is over the exact bytes we sent. Parsing and re-serialising changes key order and whitespace, and the signature will never match. Capture the raw body.
- Comparing with
===. String comparison short-circuits on the first differing byte, which leaks timing. UsetimingSafeEqual. - Ignoring the timestamp. Without the tolerance check, a captured request stays valid forever. Five minutes is a sensible window.
Writing the handler
Acknowledge quickly and do the work elsewhere. We time out at 10 seconds and treat a timeout as a failure, which means a slow handler turns into duplicate deliveries.
import express from 'express'
const app = express()
app.post(
'/webhooks/easarc',
// The raw body is required: JSON.parse then re-stringify will not
// reproduce the exact bytes we signed.
express.raw({ type: 'application/json' }),
async (req, res) => {
const valid = verifyEasarcSignature(
req.body.toString('utf8'),
req.get('Easarc-Signature'),
process.env.EASARC_WEBHOOK_SECRET
)
if (!valid) return res.status(400).send('invalid signature')
// Acknowledge first, work afterwards. We time out at 10 seconds.
res.status(204).end()
const event = JSON.parse(req.body.toString('utf8'))
await queue.add(event.type, event, {
// Delivery is at-least-once, so make the consumer idempotent.
jobId: event.id,
})
}
)Delivery, retries and ordering
- At-least-once. Deduplicate on
event.id. The same event can arrive twice, particularly after a timeout on your side. - Not ordered. Events can arrive out of order. Use
created_at, or re-read the object, rather than assuming sequence. - Any 2xx is success. Anything else, including a redirect, is a failure and will be retried.
- Retries run for 24 hours with exponential backoff: 10s, 30s, 2m, 10m, 30m, then hourly. After 24 hours the event is dropped and recorded as failed.
- Endpoints are auto-disabled after 72 consecutive hours of failure. We email your account owner before and when that happens.
- HTTPS only, with a publicly resolvable hostname and a valid certificate. We do not deliver to private address ranges.
Replaying missed events
GET/v1/events
List past events, filterable by type and time. Retained for 30 days.
Requires scope webhooks:manage
POST/v1/events/{id}/replay
Redeliver a single event to one of your endpoints.
Requires scope webhooks:manage
After an outage on your side, list events since the last one you processed and replay them, rather than reconstructing state from the object endpoints. That keeps your consumer on one code path.
const missed = await easarc.events.list({
from: lastProcessedAt,
type: ['order.stage_advanced', 'invoice.registered'],
})
for (const event of missed.data) {
await easarc.events.replay(event.id, { endpoint_id: 'whe_01J8V2K7' })
}