NutraSoft food manufacturing ERP software
Esc
  • GuideGetting startedThree steps to your first call and your first order, then the two shapes every response takes.
  • GuideAuthentication and keysBearer keys, test and live environments, rotation without an outage, and what each scope grants.
  • GuideTest modeA second NutraSoft account of your own, seeded with realistic data, that you can safely break.
  • PageErrorsEvery error code the API returns, what it means, what to do about it and whether to retry.
  • PageConventionsThe rules every operation follows: money, time, paging, sorting, nulls, PATCH and identifiers.
Menu

Webhooks

We POST to your server under a minute after something changes, signed, and retry for about 15 hours if you do not answer.
Updated

In short

  • Reply 200 immediately, then do your work. You have 15 seconds.
  • Verify the signature over the raw request body, before parsing it.
  • Delivery is at least once and is not ordered. Deduplicate on the event id.
  • Every event says who caused it, so you can ignore the changes your own integration made.

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.

Webhooks or polling?

Both, in practice. They answer different questions and the honest rule is short: webhooks to react, updated_since to reconcile.

Webhooksupdated_since polling
Latencyunder a minuteHowever often you poll
Line changesYes: a line edit reports its parent documentNo: tracks the record only
DeletionsYesNo
If your server is downWe retry for about 15 hoursYou catch up on the next poll
Rate limit costNone, we call youCounts against your 300/min
Run a reconciling 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.

Setting one up

  1. Add the endpoint

    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.

  2. Copy the signing secret

    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.

  3. Send a test event

    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.

  4. Tick the events you want

    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.

What we send

json
{
  "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"
    }
  }
}
FieldMeaning
idUnique event id. Deduplicate on this.
typeThe topic, e.g. sales_order.status_changed.
livemodefalse for events from your test account.
sourceWho caused the change. See below.
data.changedFields whose value actually moved. Empty on a .created.
data.snapshotA 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.
HeaderMeaning
NutraSoft-Signaturet=<unix>,v1=<hex>. Verify this.
NutraSoft-TopicSame as type, so you can route without parsing.
NutraSoft-Event-IdSame as id.
NutraSoft-Delivery-IdThis attempt. Quote it when you contact support.
NutraSoft-Attempt1 on the first try.
Idempotency-KeySame as id, for receivers that already use the convention.

Verifying the signature

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.

Sign the raw body, exactly as received. Parsing the JSON and re-serialising it changes the bytes, and the signature will never match. This is the single most common integration mistake.
javascript
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)))
    );
}
python
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
<?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;
}

Not hearing your own writes

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.

If your handler writes back to NutraSoft, that write produces an event too. Left alone, your integration hears its own echo and the two of you keep going. Break the loop on purpose, one of the two ways below.
  1. In your code: compare 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.
  2. In NutraSoft, with no code at all: on the endpoint, set *Changes made through the API* to skip your keys. Changes made by people in the ERP, by the scheduler and by our own integrations still arrive.
javascript
// 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.

Retries

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:

AttemptSent
1under a minute
25 hours after the previous attempt
35 hours after the previous attempt
45 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.

  • 429: we honour your Retry-After.
  • 410 Gone: we take it as a deliberate unsubscribe and switch the endpoint off immediately. Useful when a receiver is decommissioned.
  • A redirect: treated as a failure. We never follow one.
  • If an endpoint fails everything for a day, we switch it off and email your account's developer contacts. Nothing is deleted; one click re-enables it.

What we guarantee, and what we do not

At least onceA 200 lost in transit looks identical to no answer, so we retry. You may see the same event twice, so deduplicate on id.
Not orderedA .updated can arrive before the .created for the same record. Do not infer sequence from arrival.
created_at is not a sequenceTwo events can share a timestamp. Track ids, not times.
The GET is the truthsnapshot was accurate when the event was made. The API is accurate now.

Rotating the secret

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.

Test mode

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.

Troubleshooting

SymptomUsually
Signature never matchesThe body was parsed and re-serialised before verifying. Use the raw bytes.
Events arrive twiceExpected. Deduplicate on id.
Nothing arrives at allNo events are ticked on the endpoint. A new one starts with none.
Only some changes arriveThe rest are not ticked, or your API-source filter is skipping your own writes.
Everything times outThe handler is doing its work before replying. Reply 200 first.
Endpoint switched itself offIt failed every delivery for a day. Fix it and re-enable.
We do not publish fixed IP addresses to allowlist: deliveries come from serverless infrastructure and the addresses change. Verify the signature instead; it is a stronger check than an IP range, which anyone inside the same cloud shares with you.

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.