Webhooks

Polling works, and for a nightly reconciliation it is the right tool. But most integrations want to know when something happened, not whether it happened since the last time they asked. A webhook is NolaPro making an HTTPS POST to your server shortly after a record changes — see How soon for what "shortly" means, because it is not instant and you should not design as though it were.

Subscribe with POST /webhooks, naming the events you want and the URL to send them to.

{
  "url": "https://example.com/hooks/nolapro",
  "events": ["orders.created", "shipments.created", "invoices.posted"],
  "description": "Storefront sync"
}

The response is the only time the signing secret is returned. Store it before you acknowledge the call — the same rule as the API token itself.

The envelope#

Every event has the same shape. Only type and data change.

{
  "id": "evt_01J8ZQ4M2K7X",
  "type": "shipments.created",
  "createdate": "2026-08-01T14:22:05Z",
  "companyid": 1,
  "data": { "id": 8842, "orderid": 4021, "shipdate": "2026-08-01", "lines": [] }
}

data holds the record in the same shape GET returns it. It is a snapshot from when the event fired. On a retry an hour later it may already be out of date, so if you need certainty — a stock level, a balance — re-read the record rather than trusting the payload.

Verify the signature. Always#

Every delivery carries an NP-Signature header:

NP-Signature: t=1785938525,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

v1 is HMAC-SHA256(secret, "<t>.<raw request body>"), hex encoded.

To verify:

  1. Take t and the raw body, exactly as received. Do not parse and re-serialise it first — key order and whitespace will differ and the signature will not match.
  2. Compute the HMAC with your subscription secret.
  3. Accept if it matches ANY v1 in the header, not only the first. There is normally one, and two while a secret rotation is in flight — see Rotating the secret. A receiver that checks only one signature works today and breaks the first time you rotate.
  4. Compare using a constant-time comparison, not ==.
  5. Reject anything where t is more than five minutes old. Without this check a signature stays valid forever, and a single captured request can be replayed at any time.

An unsigned or badly signed request is not from us. There is no fallback and no unsigned mode.

Answer fast, work later#

Answer with any 2xx. We give up after 15 seconds, so treat 10 as your budget and leave yourself room.

This is not a style preference. We retry a timeout, so a handler that takes twenty seconds to do its job turns one event into a stream of duplicates, each of which takes twenty seconds. The usual shape is: write the event to your own queue, return 200, process it on your own schedule.

Return 410 Gone to tell us the subscription is dead. We disable it immediately rather than retrying for two days.

How soon#

Up to five minutes, usually less. Events are queued the instant the record is saved, and a background job pushes them on a five-minute cycle. So the floor is "almost immediately" and the ceiling is one cycle plus however long the queue ahead of you takes to drain.

That delay is deliberate. The alternative is delivering on the save itself, which would mean your server's response time becomes part of our users' Save button — a slow endpoint would slow down the person entering the order, and a hung one would hold a database transaction open while it waited. No integration is worth that, so events are queued and pushed separately.

What this means for you:

  • Do not build a live UI on webhook arrival. If a human is watching a screen waiting for something to appear, poll the resource; five minutes is a long time to stare at a spinner.
  • Do build reconciliation on it. Five minutes is nothing against a nightly sync, and you stop hammering endpoints for records that have not changed.
  • For an immediate answer while you are wiring it up, use POST /webhooks/{id}/test — that one sends synchronously and hands you back exactly what your endpoint said.

Delivery is in order, at-least-once, and may drop#

Three properties worth designing around.

Events arrive in the order they happened. Per subscription, strictly. orders.updated can never reach you before the orders.created for the same order. This is stronger than most webhook systems offer, and it is deliberate: an update that lands before its create describes a record the receiver has never heard of, and there is no sane way for your code to recover from that on its own.

The same event can arrive twice. A network failure after your server committed but before we saw the response looks identical to a failure before it committed, so we retry. Deduplicate on the envelope id, which is stable across every retry of the same event.

An event can be dropped, and that is the price of the first property. We would rather you never receive an event than receive it out of order — see below.

Retry, and why we give up quickly

An event is attempted up to three times, on consecutive delivery cycles, roughly five minutes apart. If the third fails, that event is abandoned and the queue moves on.

While an event is waiting to be retried, nothing behind it is sent. The whole subscription pauses at that point in the stream. That is what keeps the order intact, and it is why the retry window is minutes rather than the hours a backoff ladder would take: everything queued behind a failing event is waiting on it.

So there is no long ladder to wait out. A receiver that is down for twenty minutes will lose the events that fell in that window and then carry on in order, rather than receiving fifteen hours of history in the wrong sequence once it comes back.

Note

This is the trade, stated plainly. A conventional webhook system defers a failed event and keeps delivering the newer ones, so nothing is lost but the stream is scrambled. We do the opposite. If losing an occasional event is worse for you than receiving one late, poll modifiedsince alongside the webhook — that is the belt-and-braces shape described at the end of this page, and it is the right answer for anything you must never miss.

After 24 consecutive failed attempts — about two hours of an endpoint being continuously unreachable — the subscription is disabled and disableddate is set. Nothing is queued while a subscription is disabled: events that fire are dropped, not held.

Re-enable it once you have fixed whatever broke:

PATCH /webhooks/{id}   { "enabled": true }

That clears disableddate and resets failcount. Clearing the date alone would disable it again on the very next failure. Use modifiedsince on the resources you care about to catch up on the gap — the events that fired while it was disabled are gone.

A 2xx at any point resets failcount to zero.

410 Gone still ends it immediately. If your endpoint answers 410, we disable the subscription on the spot rather than spending three attempts finding out.

Pausing one yourself

enabled above is for a subscription we disabled. To stop delivery on your own terms - a maintenance window, a migration, a receiver you are rebuilding - use paused:

PATCH /webhooks/{id}   { "paused": true }

A paused subscription drops events, it does not hold them. It is a tap, not a buffer, and the same is true of one we disabled. Nothing queues up to arrive in a burst when you turn it back on, which is deliberate: replaying hours of history at an endpoint that has just come back up is how a maintenance window becomes an outage. Use modifiedsince on the resources you care about to catch up on the gap.

The two switches are independent and mean different things. paused is yours and resets nothing; enabled: true is the recovery from our six failures and clears disableddate and failcount.

What each delivery carries

HeaderValue
NP-Signaturet=<unix seconds>,v1=<hex hmac> — see above
NP-Event-IdThe envelope id, so you can dedupe without parsing the body
NP-Event-TypeThe event name, so you can route without parsing the body
NP-Delivery-Attempt1 on the first try, incrementing on each retry

How long we keep the record

A delivered event is purged after 7 days; one that exhausted its attempts is kept 30 days, so a failure is still diagnosable long after it happened. The payload is stored with it, which is why the successful ones do not linger.

Testing without waiting for a real event#

POST /webhooks/{id}/test

Sends a webhooks.test event right now and returns what your endpoint answered — the status code, the first 500 bytes of the body, and the round-trip time.

This exists because "the webhook is not firing" is almost always a 500 on the receiving end that nobody could see. Your own logs may not have it; ours will.

Limited to 10 calls a minute, in its own count - every call makes a real outbound request to your server, so a loop here costs your machine as well as ours. Past that you get 429 with a Retry-After. Ordinary traffic can never use this budget up: it is counted separately from the 600/minute everything else shares.

Seeing what actually happened#

GET /webhooks/{id}/deliveries

Every attempt we have made for this subscription, newest first: the event, how many times it was tried, the status code your endpoint returned, the first 500 bytes of its response body, and the round trip in milliseconds.

This is the answer to "the webhook is not firing". It is almost always a 500 on the receiving end that never reached your own logs, and the response body is usually the stack trace that explains it.

{
  "eventid": "evt_01J8ZQ4M2K7X",
  "eventtype": "orders.created",
  "attempts": 3,
  "delivered": false,
  "abandoned": true,
  "responsecode": 500,
  "responsebody": "TypeError: Cannot read properties of undefined (reading 'lines')",
  "durationms": 84,
  "entrydate": "2026-08-02T14:22:05Z"
}

Filter with delivered, abandoned and eventtype. abandoned: true is the list you want after an outage — those are the events that used up their attempts and were dropped, so they are exactly what modifiedsince needs to backfill.

Delivered attempts are kept 7 days; dropped ones 30 days, because a failure is what you need to look at later. Anything still owed to a subscription you have deleted is kept a week and then goes too. The payload itself is not returned: it is a snapshot that may already be stale, and re-reading the record is the better answer.

Reading deliveries needs setup:read, the same as reading the subscription.

Sending the backlog now#

POST /webhooks/{id}/flush

Delivers what this subscription is owed immediately instead of waiting for the next five-minute cycle, and clears any remaining retry wait first.

That second part is the point. Because delivery is strictly ordered, an event sitting out its retry gap holds up everything queued behind it. Once you have fixed your endpoint there is otherwise nothing to do but wait for a timer. This drops the wait.

{ "sent": 4, "failed": 0, "pending": 0 }

Ordering is untouched: still oldest first, still stopping at the first failure, and an event that has already been abandoned stays abandoned — bringing it back would put it behind events that have already gone out, which is the one thing this design will not do.

Needs setup:write, and is limited like the test endpoint because it makes real outbound requests.

Rotating the secret#

POST /webhooks/{id}/rotatesecret

Issues a new signing secret and returns it once, the same rule as create. Use it when the old one has been exposed, when someone with access to it leaves, or on whatever schedule your own policy sets.

Both secrets sign for 24 hours. That is the point of the endpoint rather than a convenience: you cannot change your stored secret at the same instant we change ours, so a straight swap would guarantee a window where every delivery fails your signature check — and an event that runs out of attempts is dropped, not delayed. A rotation that quietly lost events would be a strange thing for a security operation to do.

During the overlap NP-Signature carries two signatures, the old one first:

NP-Signature: t=1785938525,v1=<old>,v1=<new>

Order is deliberate. A receiver that reads only the first v1 keeps working, untouched, on the secret it already has — so nothing breaks at the moment you rotate. You then deploy the new secret whenever suits you, and when the window closes only the new signature is sent.

{
  "secret": "9c1f…",
  "previoussecretvaliduntil": "2026-08-05T16:49:32Z"
}

If you can deploy the new secret atomically, skip the overlap:

POST /webhooks/{id}/rotatesecret   { "immediate": true }

The old secret stops working the moment that returns, and previoussecretvaliduntil comes back null.

There is no way to read a secret back, rotated or original. If you lose one, rotate again — that is the recovery, and it is why a secret is stored to sign with rather than to hand out.

Rotating needs setup:write, the same as changing any other part of the subscription.

Scopes#

A subscription belongs to the token that created it, and only ever delivers events that token has the scope to read. Subscribing to invoices.posted with a token that lacks invoices:read is accepted at create time but delivers nothing — so check GET /me if a subscription is silent.

Listing and reading subscriptions needs setup:read; creating, changing and testing them needs setup:write. Deleting one needs setup:cancel, which setup:write does not include — the same rule as everywhere else in the API: creating a thing and destroying it are different permissions.

Event catalogue#

The full list, with the scope each one needs, is on Webhook subscriptions — that page also documents the subscription object and its endpoints. The short version:

EventFires whenLive
customers.created / .updated / .cancelledA customer record changes. updated includes credit limit and holdyes
orders.createdAn order, quote or service order is createdyes
orders.updatedThe header or any line changes. Fires once for the order, not once per lineyes
orders.cancelledAn order is cancelled and its committed stock releasedyes
orders.invoicedAn order produced one or more invoicesyes
shipments.created / .updatedGoods went out, or the shipment was amendedyes
invoices.postedAn invoice hit the ledger. Not raised for a draft — its totals can still change and it has not touched the booksyes
invoices.cancelledA posted invoice was cancelled and reversedyes
shipments.cancelledA shipment was reversedyes
payments.created / .cancelledA payment was applied or reversedyes
orderdeposits.createdA deposit was taken against an orderyes
purchaseorders.created / .updated / .cancelledA PO changedyes
invreceives.createdGoods were received against a POyes
items.created / .updatedAn item changedyes
items.stockchangedOn-hand or available moved at a locationyes
serviceordertimes.billedLogged hours became order linesyes
itemidentifierdetails.created / .updatedA serial, lot or expiry value was recorded or changedyes

Every event in this table is live. Naming one that does not exist is still refused with 422 unknown_value rather than silently accepted, so a subscription always means what it says.

Serial and lot values still cannot be delta-polled, and this is how you keep up with them. GET /itemidentifierdetails reads them fine, but their table has no auto-maintained lastchangedate, so modifiedsince is not offered on it — polling means paging the whole list. itemidentifierdetails.* fires on write and needs no such column, so it closes that gap without waiting for the schema change.

items.stockchanged is high volume. Every shipment, receipt, adjustment and transfer fires one per item per location. Subscribe to it only if you genuinely mirror stock; if you just want to show availability, GET /items/{id}/stock on demand is cheaper for both of us.

There is deliberately no * wildcard. Naming your events is what stops a new event type appearing in your handler unannounced when we add one.

When not to use a webhook#

  • Bulk reconciliation. Use modifiedsince. Replaying six months of events to rebuild a warehouse is slower and less reliable than one paged read.
  • Anything you need a guaranteed answer for. Delivery is best-effort with a bounded retry. If you must never miss a record, poll modifiedsince on a schedule as well — belt and braces is the normal production shape, not a sign something is wrong.