Skip to content

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

Every webhook event type Easarc emits
TypeFires when
order.createdAn order was created, by API or in the dashboard
order.updatedAny field changed. The payload carries a `changed` array
order.stage_advancedA production stage was completed
order.cancelledAn order was cancelled
invoice.registeredThe IRP returned an IRN and signed QR
invoice.failedIRP registration failed. Payload carries the IRP error codes
invoice.cancelledAn IRN was cancelled within the 24-hour window
eway_bill.part_b_completedVehicle recorded, validity clock started
eway_bill.expiringFires four hours before an e-way bill expires
dispatch.recordedGoods left the gate
dispatch.deliveredDelivery confirmed, with proof of delivery if supplied
payment.receivedA payment was reconciled against an invoice
payment.overdueAn invoice passed its due date unpaid
vision.defect.detectedA detection above your confidence threshold
vision.threshold.breachedDefect rate crossed your configured limit
vision.camera.offlineNo frames received for longer than the grace period
vision.qc_report.completedAn asynchronous QC report finished generating
desk.conversation.escalatedThe 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.

order.stage_advanced
{
  "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.

verify-signature.js
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. Use timingSafeEqual.
  • 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.

server.js
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.

JavaScript
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' })
}