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.
- Build the endpoint. A public HTTPS URL that accepts POST, verifies the signature and returns a 2xx status.
- Enter the URL in Nuport and save. Nuport immediately sends a
webhook.testevent. The configuration is stored only if your endpoint answers 2xx within 5 seconds. - Handle live events. From then on each subscribed change arrives as a separate request, retried automatically if your endpoint is unavailable.
2. Endpoint requirements
| Requirement | Detail |
|---|---|
| Protocol | HTTPS with a certificate issued by a public CA, serving the full chain including intermediates. Nuport's client does not fetch missing intermediates. See Troubleshooting. |
| Method | POST only. Redirects are not followed. |
| Response | Any 2xx status. The body is ignored but logged, so keep it small. |
| Timeout | Respond within 5 seconds. Acknowledge first, then process the event asynchronously. |
| Body access | Your framework must give you the raw request body bytes for signature verification. Re-serialised JSON will not match. |
| Unknown fields | Ignore 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:
| Header | Value |
|---|---|
Content-Type | application/json |
X-Nuport-Event | The event key, for example order.approved. Inventory requests may carry several keys joined by commas. |
X-Nuport-Delivery | Unique ID of this delivery. Stays the same across retries of the same event, so use it to deduplicate. |
X-Nuport-Timestamp | Unix time in seconds when the request was signed. |
X-Nuport-Signature | Lower-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 inX-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:
| Message | Meaning |
|---|---|
| The endpoint responded with status N | Your server answered but not with 2xx. A 401 usually means signature verification failed. |
| The endpoint did not respond within 5 seconds | Return 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 key | Sent when | orderStatus |
|---|---|---|
order.pending | Order created | Pending |
order.on_hold | Put on hold | On Hold |
order.approved | Approved | Approved |
order.processing | Picking / packing started | Processing |
order.ready_to_ship | Packed and ready | Ready to Ship |
order.in_transit | Handed to courier | In Transit |
order.delivered.payment_due | Delivered, payment outstanding | Payment Due |
order.delivered.payment_collected | Delivered and paid | Payment Collected |
order.flagged.pending_returned | Return initiated | Pending Returned |
order.flagged.returned | Return received | Returned |
order.flagged.damaged | Marked damaged | Damaged |
order.cancelled | Cancelled | Cancelled |
Payload fields:
| Field | Type | Notes |
|---|---|---|
event | string | Always present. |
invoiceNumber | string | Nuport order number. |
orderSource | string | Channel name, e.g. Facebook, Website. |
orderStatus | string | Display label from the table above. |
statusChangedAt | ISO 8601 | When the order entered this status. |
customerName, customerNumber, customerAddress | string | Customer details. |
products[] | array | Each with productId, name, sku, quantity, price, discountAmount. |
websiteOrderId, websiteUrl, orderOrigin, ipAddress | string | Only 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 key | Field |
|---|---|
inventory.available_quantity | availableQuantity |
inventory.processing_stock | processingStock |
inventory.in_transit_stock | inTransitStock |
inventory.returning_stock | returningStock |
inventory.stock_value | stockValue |
inventory.purchase_cost | purchaseCost |
inventory.shortage_quantity | shortageQuantity |
inventory.wastage | wastage |
inventory.expired | expired |
{
"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
429or5xx. A4xxother 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-Deliveryvalue. 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
statusChangedAtto 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.
