Integration Hub sends a signed HTTPS POST to your URL when something happens in a nopCommerce store. This guide
covers what arrives, how to check it, and how delivery behaves. It applies to release 1 (schema version 1).
The request
POST /your/webhook HTTP/1.1
Content-Type: application/json; charset=utf-8
User-Agent: Cartenvo-IntegrationHub/1 (+https://cartenvo.com/plugins/integration-hub)
X-Cartenvo-Event-Id: evt_3f9c2a7d0b1e4c55a1d2e3f4a5b6c7d8
X-Cartenvo-Event-Type: order.paid
X-Cartenvo-Delivery-Id: 1042
X-Cartenvo-Attempt: 1
X-Cartenvo-Timestamp: 1793612400
X-Cartenvo-Signature: sha256=5d1f0c…| Header | Meaning |
|---|---|
X-Cartenvo-Event-Id | The event. The same on every retry and for every webhook that receives it: use it to drop repeats. |
X-Cartenvo-Event-Type | The event name, also in the body's type. |
X-Cartenvo-Delivery-Id | This message (one event for one webhook), as shown in the delivery log. |
X-Cartenvo-Attempt | 1 for the first try, then 2, 3… |
X-Cartenvo-Timestamp | Unix seconds when this attempt was signed. |
X-Cartenvo-Signature | sha256= and the hex HMAC-SHA256 of "{timestamp}.{raw body}" with the webhook's secret. |
The body
{
"id": "evt_3f9c2a7d0b1e4c55a1d2e3f4a5b6c7d8",
"type": "order.status_changed",
"schemaVersion": 1,
"occurredOnUtc": "2026-11-02T09:30:00.000Z",
"store": { "id": 1, "name": "Demo store", "url": "https://shop.example.com/" },
"data": { "...": "the order, shipment, product, customer…" },
"context": { "orderStatus": { "from": "Pending", "to": "Processing" } }
}datais the entity as it was when the message was built (within a minute of the event, on the first attempt). If it no longer exists,datais{"id": 123, "missing": true}.contextholds what only the moment of the event knows: old and new statuses, the refunded amount, the stock change. It'snullfor events that need none.- Messages sent from admin with Preview → Send to this webhook are real-shaped samples: their
contexthas"sample": true. - Amounts are in the store's primary currency (
data.currencyon orders). Dates are UTC with aZ. storeisnullfor events that belong to no store (products) when the shop has several stores.- Orders, products and customers carry
externalReferences: their numbers in other systems, set through the sync API, such as[{"system": "odoo", "reference": "SO0042", …}].
Full examples of every shape are frozen in Tests/IntegrationHub.Core.Tests/Shapes;
the admin Preview tab builds any event from a real order, product or customer.
Short format
A webhook set to the short format gets data as {"object": "order", "id": 42, "adminUrl": "…"} and the same
context. Use it when the receiver looks the rest up itself.
Events
| Event | data | context |
|---|---|---|
order.placed | order | — |
order.paid | order | — |
order.authorized | order | — |
order.status_changed | order | orderStatus, paymentStatus, shippingStatus: {from, to}, only the ones that changed |
order.cancelled | order | orderStatus {from, to} |
order.refunded | order | amount, full (true when fully refunded), refundedTotal |
order.voided | order | — |
shipment.created | shipment | — |
shipment.sent | shipment | — |
shipment.delivered | shipment | — |
shipment.tracking_set | shipment | previousTrackingNumber |
shipment.ready_for_pickup | shipment | — |
product.created | product | — |
product.updated | product | — (stock-only changes raise product.stock_changed instead) |
product.deleted | product (deleted: true) | — |
product.stock_changed | stock change: productId, combinationId, sku, warehouseId, oldQuantity, newQuantity, adjustment, reason | — |
product.price_changed | product | price, oldPrice, productCost: {from, to}, only the ones that changed |
customer.registered | customer | — |
customer.activated | customer | — |
customer.updated | customer | — |
customer.deleted | {id, deleted: true} only | — |
newsletter.subscribed | subscription: id, guid, email, active, storeId, createdOnUtc | — |
newsletter.unsubscribed | subscription | — |
review.approved | review | — |
test.ping | a message | — (sent by "Send test" to every webhook, whatever events it chose) |
Statuses are nopCommerce's names: orders Pending, Processing, Complete, Cancelled; payments Pending,
Authorized, Paid, PartiallyRefunded, Refunded, Voided; shipping ShippingNotRequired, NotYetShipped,
PartiallyShipped, Shipped, Delivered.
How changes are detected
nopCommerce's own OrderStatusChangedEvent fires only on some paths (nopCommerce issue #7825), and its update events
carry only the new values. Integration Hub remembers the last status, price and tracking number it saw for each order,
product and shipment, and compares on every save. So order.status_changed, product.price_changed and
shipment.tracking_set also fire for changes made by admin, other plugins, imports that use nopCommerce's services,
and scheduled tasks.
Two limits:
- An order changed for the first time after installation is reported from core's event, which has the old order status but not the old payment status.
- Bulk SQL updates and imports that skip nopCommerce's services raise no events at all. The sync API's changes feed catches products changed that way once a day.
Answering and retries
- Answer with any 2xx status within 15 seconds. Do slow work (calls to your ERP) after answering, or queue it.
- Anything else is a failure: other status codes, timeouts, refused connections, redirects (they aren't followed).
- Failed messages are tried again after 1, 2, 4, 8… minutes, at most 6 hours apart, 10 attempts in all (about
8½ hours). A
Retry-Afterheader asking for longer is honoured. - After the last attempt the message waits in the delivery log as Failed until someone resends it, and the store owner gets an email (at most one per webhook every 6 hours).
- If the receiver can't be reached at all, every waiting message for it counts the attempt, so none waits behind another.
Repeats and order
- A message can arrive more than once (a timeout after your server processed it, a manual resend). Store the event ID and ignore ones you've seen.
- Messages about one order or product go out in order: a later message waits while an earlier one is retrying.
Messages that failed for good don't hold others back, and different orders or products don't wait for each other.
Compare
occurredOnUtc(and a product'supdatedOnUtc) if order matters to you. - New fields can be added to any shape at any time. Existing fields change only with a new
schemaVersion.
Checking the signature
Rebuild "{X-Cartenvo-Timestamp}.{raw body}", HMAC-SHA256 it with the secret, compare in constant time, and refuse
timestamps more than 5 minutes from your clock. Use the raw body exactly as received, before any JSON parsing.
Node.js
const crypto = require('crypto');
function isValid(rawBody, headers, secret) {
const timestamp = headers['x-cartenvo-timestamp'];
const signature = headers['x-cartenvo-signature'] || '';
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = 'sha256=' + crypto.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`).digest('hex');
return signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}Python
import hashlib, hmac, time
def is_valid(raw_body: bytes, headers, secret: str) -> bool:
timestamp = headers.get("X-Cartenvo-Timestamp", "0")
signature = headers.get("X-Cartenvo-Signature", "")
if abs(time.time() - int(timestamp)) > 300:
return False
message = timestamp.encode() + b"." + raw_body
expected = "sha256=" + hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature, expected)PHP
function is_valid(string $rawBody, string $secret): bool {
$timestamp = $_SERVER['HTTP_X_CARTENVO_TIMESTAMP'] ?? '0';
$signature = $_SERVER['HTTP_X_CARTENVO_SIGNATURE'] ?? '';
if (abs(time() - (int)$timestamp) > 300) return false;
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
return hash_equals($expected, $signature);
}
// $rawBody = file_get_contents('php://input');C#
using System.Security.Cryptography;
using System.Text;
static bool IsValid(string rawBody, string timestamp, string signature, string secret)
{
if (!long.TryParse(timestamp, out var ts) ||
Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) > 300)
return false;
var hash = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret),
Encoding.UTF8.GetBytes($"{timestamp}.{rawBody}"));
var expected = "sha256=" + Convert.ToHexStringLower(hash);
return CryptographicOperations.FixedTimeEquals(
Encoding.ASCII.GetBytes(expected), Encoding.ASCII.GetBytes(signature ?? ""));
}Go
func isValid(rawBody []byte, timestamp, signature, secret string) bool {
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil || math.Abs(float64(time.Now().Unix()-ts)) > 300 {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp + "."))
mac.Write(rawBody)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}A complete receiver is in samples/receiver-node.
Network rules
- URLs must be
https://. Plainhttp://is accepted only forlocalhost, for testing. - With the private-network block on (the default), addresses on loopback, private (10.x, 172.16–31.x, 192.168.x), link-local (including cloud metadata at 169.254.169.254), carrier-grade NAT and multicast ranges are refused, for IPv4 and IPv6. Host names are resolved and checked on every connection, so a name can't be pointed at an internal address after it was saved.
- nopCommerce's proxy settings (
appsettings.json) are used when they're on. - Turn the block off only to test with a receiver on the same computer or network, and turn it back on afterwards.
Extending from another plugin
Register your implementations in your plugin's INopStartup (scoped). Raise your own event:
public class CompanyCreatedConsumer(IIntegrationEventPublisher hub) : IConsumer<EntityInsertedEvent<Company>>
{
public async Task HandleEventAsync(EntityInsertedEvent<Company> e)
{
if (await hub.IsWantedAsync("b2b.company_created"))
await hub.RaiseAsync("b2b.company_created", "company", e.Entity.Id, e.Entity.StoreId);
}
}and describe it, with its data, through an IIntegrationEventSource:
public class B2BEvents(ICompanyService companies) : IIntegrationEventSource
{
public IEnumerable<EventDefinition> GetEvents() => [new("b2b.company_created", "b2b", "company")];
public string GroupTitle(string group) => group == "b2b" ? "B2B" : null;
public async Task<JsonNode> BuildDataAsync(IntegrationEvent e)
=> await companies.GetByIdAsync(e.EntityId) is { } company
? JsonSerializer.SerializeToNode(new { company.Id, company.Name }, Payloads.Json)
: null;
}IWebhookFilter, IWebhookPayloadEnricher, IWebhookTransport and IHubAdminExtension (tabs and links on the
Hub's admin pages) work the same way; see
Extensibility/Extensions.cs. ExtensionApi.Version goes up whenever members
are added.