In short
id.A webhook is us making an HTTP request to a URL you own. When something changes in NutraSoft, we POST a small signed JSON body to that URL. You do not have to poll for it.
Both, in practice. They answer different questions and the honest rule is short: webhooks to react, updated_since to reconcile.
| Webhooks | updated_since polling | |
|---|---|---|
| Latency | under a minute | However often you poll |
| Line changes | Yes: a line edit reports its parent document | No: tracks the record only |
| Deletions | Yes | No |
| If your server is down | We retry for about 15 hours | You catch up on the next poll |
| Rate limit cost | None, we call you | Counts against your 300/min |
updated_since sweep on a schedule even with webhooks working. It is how you notice a gap rather than discover one months later, and it costs one request.In NutraSoft, go to Settings → Developer and choose Add webhook. The URL must be https://; we do not follow redirects. You can have up to 20 endpoints per account.
Shown when the endpoint is created. It starts with whsec_ and is what proves a request came from us. You can reveal it again later, but store it as you would any other credential.
Use Send test on the row. It sends a ping to your real URL signed with your real secret, and shows you exactly what came back: the status code, or the connection error. Get this green before going further.
A new endpoint has nothing ticked and sends nothing. Turn on only the events you will act on; you can add more at any time.
{
"id": "evt_9f3c2a7b4d1e4f8ca1b2c3d4e5f60718",
"type": "sales_order.status_changed",
"created_at": "2026-09-16T14:03:11.412Z",
"api_version": "1.0",
"livemode": true,
"source": { "type": "webapp" },
"data": {
"object_type": "sales_order",
"id": 41207,
"changed": ["status"],
"snapshot": {
"id": 41207,
"sales_order_no": "SO-1042",
"status": "SHIPPED",
"updated_at": "2026-09-16T14:03:11.402Z"
}
}
}| Field | Meaning |
|---|---|
id | Unique event id. Deduplicate on this. |
type | The topic, e.g. sales_order.status_changed. |
livemode | false for events from your test account. |
source | Who caused the change. See below. |
data.changed | Fields whose value actually moved. Empty on a .created. |
data.snapshot | A few scalar fields as they were at the time, to route on. |
snapshot is a routing aid, not the record. It never contains prices, costs or totals, and it may be out of date by the time you read it. GET the resource for current, complete state.| Header | Meaning |
|---|---|
NutraSoft-Signature | t=<unix>,v1=<hex>. Verify this. |
NutraSoft-Topic | Same as type, so you can route without parsing. |
NutraSoft-Event-Id | Same as id. |
NutraSoft-Delivery-Id | This attempt. Quote it when you contact support. |
NutraSoft-Attempt | 1 on the first try. |
Idempotency-Key | Same as id, for receivers that already use the convention. |
Anyone can POST to your URL. The signature is what tells you a request is really from us. Compute HMAC-SHA256 over {timestamp}.{raw body} with your signing secret, and compare in constant time. Reject anything older than 5 minutes.
const crypto = require("crypto");
// Express: keep the raw bytes. express.json() alone discards them.
app.post("/hooks/nutrasoft",
express.raw({ type: "application/json" }),
(req, res) => {
if (!verify(req.body, req.get("NutraSoft-Signature"), process.env.WHSEC)) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body.toString("utf8"));
// Answer first. Do the slow work afterwards, or we will time out
// and send the whole thing again.
res.sendStatus(200);
enqueue(event);
});
function verify(rawBody, header, secret) {
if (!header) return false;
const parts = Object.fromEntries(
header.split(",").map((p) => p.split("=", 2))
);
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(parts.t));
if (!Number.isFinite(age) || age > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(parts.t + "." + rawBody)
.digest("hex");
// Every v1= in the header: during a secret rotation there are two.
return header
.split(",")
.filter((p) => p.startsWith("v1="))
.some((p) =>
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(p.slice(3)))
);
}import hashlib, hmac, time
TOLERANCE = 300
def verify(raw_body: bytes, header: str, secret: str) -> bool:
if not header:
return False
fields = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
try:
timestamp = int(fields["t"])
except (KeyError, ValueError):
return False
if abs(int(time.time()) - timestamp) > TOLERANCE:
return False
expected = hmac.new(
secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256
).hexdigest()
# Any v1= matches: two are sent while a secret is being rotated.
return any(
hmac.compare_digest(expected, value)
for key, value in (p.split("=", 1) for p in header.split(",") if "=" in p)
if key == "v1"
)<?php
function nutrasoft_verify(string $rawBody, ?string $header, string $secret): bool {
if (!$header) return false;
$fields = [];
foreach (explode(',', $header) as $part) {
[$k, $v] = array_pad(explode('=', $part, 2), 2, null);
$fields[$k][] = $v;
}
$t = (int)($fields['t'][0] ?? 0);
if ($t === 0 || abs(time() - $t) > 300) return false;
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
foreach ($fields['v1'] ?? [] as $candidate) {
if (hash_equals($expected, (string)$candidate)) return true;
}
return false;
}Every event carries source, saying what caused the change: webapp (a person in NutraSoft), system (a scheduled job or one of our integrations), or api naming the key that made the call by its key_prefix and key_last4.
source.key_prefix with the start of the key you hold and return 200 without acting. Both values come straight off the key in your own configuration, so there is nothing extra to fetch or store, and rotating the key keeps working.// Skip the echo of our own writes. Both halves are derived from the
// key already in our configuration, so a rotated key needs no change here.
const ours = {
prefix: NUTRASOFT_API_KEY.slice(0, 12),
last4: NUTRASOFT_API_KEY.slice(-4),
};
if (
event.source.type === "api" &&
event.source.key_prefix === ours.prefix &&
event.source.key_last4 === ours.last4
) {
return res.sendStatus(200);
}One more thing works in your favour: a write that changes nothing produces no event at all. Writing back the same value you were just told is silent.
A delivery succeeds on any 2xx within 15 seconds. Anything else (a 500, a 404, a timeout, a bad certificate) is a failure, and we try again on this schedule:
| Attempt | Sent |
|---|---|
| 1 | under a minute |
| 2 | 5 hours after the previous attempt |
| 3 | 5 hours after the previous attempt |
| 4 | 5 hours after the previous attempt |
That is 4 attempts over about 15 hours. After the last one the delivery is marked Gave up, and stays visible in your delivery history so you can see exactly what you missed and fetch it with a GET.
You never have to wait out a gap. The five hours is for the case where nobody is watching. If you have just deployed a fix, open Deliveries on the endpoint and press Send again. It goes out on the next tick, it works on a delivery that is still waiting for its next automatic attempt as well as on one that gave up, and you can press it as many times as you need. Pressing it twice queues one delivery, not two.
Retry-After.| At least once | A 200 lost in transit looks identical to no answer, so we retry. You may see the same event twice, so deduplicate on id. |
| Not ordered | A .updated can arrive before the .created for the same record. Do not infer sequence from arrival. |
created_at is not a sequence | Two events can share a timestamp. Track ids, not times. |
The GET is the truth | snapshot was accurate when the event was made. The API is accurate now. |
Rotate secret issues a new one. For the next 24 hours we sign every delivery with both, sending two v1= values in the header, so you can deploy the new secret without an outage, which is why the snippets above check every v1= rather than just the first. Choose the immediate option instead if you believe the old secret has leaked.
Your test account is a separate account, so it has its own webhook endpoints and its own secrets. Events from it carry livemode: false. Writes you make with a nsk_test_ key produce real events there, so you can drive a full end-to-end test from your own code before touching live data.
| Symptom | Usually |
|---|---|
| Signature never matches | The body was parsed and re-serialised before verifying. Use the raw bytes. |
| Events arrive twice | Expected. Deduplicate on id. |
| Nothing arrives at all | No events are ticked on the endpoint. A new one starts with none. |
| Only some changes arrive | The rest are not ticked, or your API-source filter is skipping your own writes. |
| Everything times out | The handler is doing its work before replying. Reply 200 first. |
| Endpoint switched itself off | It failed every delivery for a day. Fix it and re-enable. |
Your delivery history is kept for 30 days under Deliveries on each endpoint, with what we sent, what came back and a Send again button. When you contact support, quote the NutraSoft-Delivery-Id.
A delivery we gave up on is kept longer, for 90 days, so a failure is still there to look at weeks after it happened. After 30 days we drop the payload and your server's reply from it, keep the rest, and Send again stops working on it: there is nothing left to send by then. Replay a failure before that, or re-read the affected records with updated_since.