Webhook Integration Guide

This guide provides the technical documentation required to integrate with Nuport’s webhook system.

Receiving webhooks from Nuport

How to build an HTTPS endpoint that accepts order, product and inventory events from Nuport, verifies they are genuine, and passes the connection test when you save the webhook in your Nuport settings.

1. How delivery works

When something changes in your Nuport account that you have subscribed to, Nuport sends one HTTPS POST request with a JSON body to the URL you configured. Every request is signed with your webhook secret so you can confirm it came from Nuport and was not altered in transit.

The secret is shown once in Settings › Webhooks and starts with whsec_. Treat it like a password: it is never sent in a request, and you must never echo it back.

  1. Build the endpoint. A public HTTPS URL that accepts POST, verifies the signature and returns a 2xx status.
  2. Enter the URL in Nuport and save. Nuport immediately sends a webhook.test event. The configuration is stored only if your endpoint answers 2xx within 5 seconds.
  3. Handle live events. From then on each subscribed change arrives as a separate request, retried automatically if your endpoint is unavailable.

2. Endpoint requirements

RequirementDetail
ProtocolHTTPS with a certificate issued by a public CA, serving the full chain including intermediates. Nuport's client does not fetch missing intermediates. See Troubleshooting.
MethodPOST only. Redirects are not followed.
ResponseAny 2xx status. The body is ignored but logged, so keep it small.
TimeoutRespond within 5 seconds. Acknowledge first, then process the event asynchronously.
Body accessYour framework must give you the raw request body bytes for signature verification. Re-serialised JSON will not match.
Unknown fieldsIgnore keys you do not recognise. Fields with no value are omitted rather than sent as null, so treat every key except event / events as optional.

3. Request format

Every request carries these headers:

HeaderValue
Content-Typeapplication/json
X-Nuport-EventThe event key, for example order.approved. Inventory requests may carry several keys joined by commas.
X-Nuport-DeliveryUnique ID of this delivery. Stays the same across retries of the same event, so use it to deduplicate.
X-Nuport-TimestampUnix time in seconds when the request was signed.
X-Nuport-SignatureLower-case hex HMAC-SHA256 of <timestamp>.<raw body> keyed with your secret.

Example of a complete live request:

POST /webhooks/nuport HTTP/1.1
Host: hfsales.ihelpbd.com
Content-Type: application/json
X-Nuport-Event: order.approved
X-Nuport-Delivery: 184233
X-Nuport-Timestamp: 1758614400
X-Nuport-Signature: 9f1c0b7d2a8e4f6a3c5d7e9b1a2c4e6f8a0b2d4f6e8c0a2b4d6f8e0a2c4b6d8e

{"event":"order.approved","invoiceNumber":"HFS-10234","orderSource":"Facebook","orderStatus":"Approved","statusChangedAt":"2026-09-23T07:42:10.000Z","customerName":"Rahim Uddin","customerNumber":"01711000000","customerAddress":"House 12, Road 5, Dhanmondi, Dhaka","products":[{"productId":"cmf6q0k3x0001abcd","name":"Leather Wallet","sku":"LW-01","quantity":2,"price":1200,"discountAmount":100}],"websiteOrderId":"4521"}

4. Verifying the signature

Compute the expected signature from the timestamp header and the raw body, then compare it with the header using a constant-time comparison. Reject the request with 401 if they differ. Optionally reject timestamps older than five minutes to prevent replay.

Common mistake: putting the whsec_… secret itself in X-Nuport-Signature. The header must contain the HMAC digest, and the secret is only ever the HMAC key. A request built that way is correctly rejected with a signature error.

Node.js (Express)

const express = require('express')
const crypto = require('crypto')

const SECRET = process.env.NUPORT_WEBHOOK_SECRET   // whsec_...
const app = express()

// Keep the raw bytes: JSON parsing must not run before verification.
app.post('/webhooks/nuport', express.raw({ type: 'application/json' }), (req, res) => {
  const timestamp = req.get('X-Nuport-Timestamp')
  const signature = req.get('X-Nuport-Signature') || ''
  const rawBody = req.body                      // Buffer

  const expected = crypto
    .createHmac('sha256', SECRET)
    .update(`${timestamp}.`)
    .update(rawBody)
    .digest('hex')

  const valid = expected.length === signature.length &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))

  if (!valid) {
    return res.status(401).json({ success: false, message: 'Signature check failed.' })
  }

  const payload = JSON.parse(rawBody.toString('utf8'))
  const deliveryId = req.get('X-Nuport-Delivery')

  // Acknowledge quickly, then do the real work.
  res.status(200).json({ success: true })
  queueForProcessing(deliveryId, payload)
})

PHP (Laravel)

// routes/api.php
Route::post('/webhooks/nuport', function (Illuminate\Http\Request $request) {
    $secret    = config('services.nuport.webhook_secret');   // whsec_...
    $timestamp = $request->header('X-Nuport-Timestamp', '');
    $signature = $request->header('X-Nuport-Signature', '');
    $rawBody   = $request->getContent();                      // raw string, not re-encoded

    $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);

    if (!hash_equals($expected, $signature)) {
        return response()->json(['success' => false, 'message' => 'Signature check failed.'], 401);
    }

    $payload    = json_decode($rawBody, true);
    $deliveryId = $request->header('X-Nuport-Delivery');

    ProcessNuportEvent::dispatch($deliveryId, $payload);      // queue it
    return response()->json(['success' => true]);
});

// Exclude the route from CSRF in app/Http/Middleware/VerifyCsrfToken.php
// protected $except = ['webhooks/nuport'];

Python (Flask)

import hmac, hashlib, os
from flask import Flask, request, jsonify

SECRET = os.environ['NUPORT_WEBHOOK_SECRET'].encode()   # whsec_...
app = Flask(__name__)

@app.post('/webhooks/nuport')
def nuport_webhook():
    timestamp = request.headers.get('X-Nuport-Timestamp', '')
    signature = request.headers.get('X-Nuport-Signature', '')
    raw_body = request.get_data()                    # bytes, untouched

    expected = hmac.new(SECRET, f'{timestamp}.'.encode() + raw_body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, signature):
        return jsonify(success=False, message='Signature check failed.'), 401

    payload = request.get_json(force=True)
    delivery_id = request.headers.get('X-Nuport-Delivery')
    enqueue(delivery_id, payload)
    return jsonify(success=True), 200

5. The connection test

Saving the webhook in Nuport sends this event first. It is signed exactly like a live event, so your verification code must already be in place. Return 200 and do nothing else with it.

X-Nuport-Event: webhook.test
X-Nuport-Delivery: test-3f9c2a1e-7d44-4b0e-9a6b-1c2d3e4f5a6b

{
  "event": "webhook.test",
  "companyId": "cmf1x2y3z0000abcd1234efgh",
  "timestamp": "2026-09-23T08:00:00.000Z",
  "message": "Nuport webhook test request. Respond with a 2xx status code within 5 seconds to confirm this endpoint."
}

If the test fails, Nuport shows the reason in the save dialog and discards the configuration:

MessageMeaning
The endpoint responded with status NYour server answered but not with 2xx. A 401 usually means signature verification failed.
The endpoint did not respond within 5 secondsReturn the response before doing slow work.
The endpoint could not be reached (…)DNS, network or TLS failure. The text in brackets is the underlying error.

To generate a correctly signed request yourself for use in Postman or a similar client, run this and paste the printed body verbatim, without reformatting:

const crypto = require('crypto')
const secret = 'whsec_...'                                   // from Nuport settings
const body = JSON.stringify({ event: 'webhook.test', companyId: 'test', timestamp: new Date().toISOString(), message: 'manual test' })
const timestamp = Math.floor(Date.now() / 1000)
const signature = crypto.createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex')
console.log({ timestamp, signature, body })

6. Event reference

Order events

One request per status change. The event key already encodes the sub status, and orderStatus carries the same value as a display label.

Event keySent whenorderStatus
order.pendingOrder createdPending
order.on_holdPut on holdOn Hold
order.approvedApprovedApproved
order.processingPicking / packing startedProcessing
order.ready_to_shipPacked and readyReady to Ship
order.in_transitHanded to courierIn Transit
order.delivered.payment_dueDelivered, payment outstandingPayment Due
order.delivered.payment_collectedDelivered and paidPayment Collected
order.flagged.pending_returnedReturn initiatedPending Returned
order.flagged.returnedReturn receivedReturned
order.flagged.damagedMarked damagedDamaged
order.cancelledCancelledCancelled

Payload fields:

FieldTypeNotes
eventstringAlways present.
invoiceNumberstringNuport order number.
orderSourcestringChannel name, e.g. Facebook, Website.
orderStatusstringDisplay label from the table above.
statusChangedAtISO 8601When the order entered this status.
customerName, customerNumber, customerAddressstringCustomer details.
products[]arrayEach with productId, name, sku, quantity, price, discountAmount.
websiteOrderId, websiteUrl, orderOrigin, ipAddressstringOnly for orders imported from a connected store.

Product events

product.created · product.updated

{
  "event": "product.updated",
  "productId": "cmf6q0k3x0001abcd",
  "name": "Leather Wallet",
  "sku": "LW-01",
  "barcode": "8901234567890",
  "internalId": "P-00042",
  "category": "Accessories",
  "type": "SIMPLE",
  "currency": "BDT",
  "price": 1200,
  "salePrice": 1100,
  "purchasePrice": 650,
  "imageUrls": ["https://…/lw-01.jpg"],
  "status": "ACTIVE",
  "tags": ["leather", "gift"],
  "createdAt": "2026-08-01T10:00:00.000Z",
  "updatedAt": "2026-09-23T07:42:10.000Z"
}

Also possible: summary, subCategory1 to subCategory5, retailPrice, distributorPrice, and parentProduct for variants.

Inventory events

Sent per product per warehouse when a subscribed metric changes. Several metrics can change in one write, so the body carries an events array and one field per metric you subscribed to, even if only some of them changed.

Event keyField
inventory.available_quantityavailableQuantity
inventory.processing_stockprocessingStock
inventory.in_transit_stockinTransitStock
inventory.returning_stockreturningStock
inventory.stock_valuestockValue
inventory.purchase_costpurchaseCost
inventory.shortage_quantityshortageQuantity
inventory.wastagewastage
inventory.expiredexpired
{
  "events": ["inventory.available_quantity", "inventory.stock_value"],
  "productName": "Leather Wallet",
  "productId": "cmf6q0k3x0001abcd",
  "productSku": "LW-01",
  "warehouseName": "HFS Warehouse",
  "warehouseId": "cmf2w9abc0003xyz",
  "currency": "BDT",
  "availableQuantity": 48,
  "stockValue": 52800
}

7. Retries and idempotency

  • A delivery counts as failed if there is no response within 5 seconds, a network or TLS error occurs, or the status is 429 or 5xx. A 4xx other than 429 is treated as permanently rejected and is not retried.
  • Failed deliveries are retried up to 5 attempts with exponential backoff starting at 10 seconds, so roughly 10 s, 20 s, 40 s and 80 s after the previous attempt.
  • Retries reuse the same X-Nuport-Delivery value. Store processed delivery IDs and skip any you have already handled.
  • Events for one order are not guaranteed to arrive in order when retries are involved. Use statusChangedAt to resolve conflicts.

8. Troubleshooting

"unable to verify the first certificate"

Your server is sending only its own certificate without the intermediate CA certificate. Browsers hide this by downloading the intermediate themselves, but Nuport's client does not. Check with:

openssl s_client -connect your-host.example:443 -servername your-host.example </dev/null 2>&1 | grep "Verify return code"

A healthy host prints Verify return code: 0 (ok). Code 21 confirms the incomplete chain. Fix it by serving the full chain file your CA supplied: in nginx point ssl_certificate at the fullchain or ca-bundle file, in Apache set SSLCertificateChainFile or concatenate the intermediate after your certificate.

Test returns 401

Your endpoint is reachable but rejected the signature. Confirm that you hash timestamp + "." + rawBody, that the body is the raw bytes and not re-encoded JSON, that the secret is the full whsec_… string with no whitespace, and that you compare against the hex digest rather than the secret.

Test returns 403 or 419

Usually a CSRF or WAF rule blocking a POST without a session. Exempt the webhook path from CSRF protection and allow requests without an Origin header.

Timed out

Move all work after the response. Database writes, calls to other APIs and email sending should happen in a queue or background job, not before the 2xx is returned.

Ready to go live? Once the test passes, the configuration is saved and live events start immediately for every event type you ticked in Nuport.