Skip to content

Cartenvo Integration Hub docs

Integration Hub webhooks: developer guide

Every Integration Hub webhook event, the JSON payloads, headers, retries and signature checks in Node.js, Python, PHP, C# and Go.

On this page

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

HTTP
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…
HeaderMeaning
X-Cartenvo-Event-IdThe event. The same on every retry and for every webhook that receives it: use it to drop repeats.
X-Cartenvo-Event-TypeThe event name, also in the body's type.
X-Cartenvo-Delivery-IdThis message (one event for one webhook), as shown in the delivery log.
X-Cartenvo-Attempt1 for the first try, then 2, 3…
X-Cartenvo-TimestampUnix seconds when this attempt was signed.
X-Cartenvo-Signaturesha256= and the hex HMAC-SHA256 of "{timestamp}.{raw body}" with the webhook's secret.

The body

JSON
{
  "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" } }
}
  • data is 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, data is {"id": 123, "missing": true}.
  • context holds what only the moment of the event knows: old and new statuses, the refunded amount, the stock change. It's null for events that need none.
  • Messages sent from admin with Preview → Send to this webhook are real-shaped samples: their context has "sample": true.
  • Amounts are in the store's primary currency (data.currency on orders). Dates are UTC with a Z.
  • store is null for 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

Eventdatacontext
order.placedorder—
order.paidorder—
order.authorizedorder—
order.status_changedorderorderStatus, paymentStatus, shippingStatus: {from, to}, only the ones that changed
order.cancelledorderorderStatus {from, to}
order.refundedorderamount, full (true when fully refunded), refundedTotal
order.voidedorder—
shipment.createdshipment—
shipment.sentshipment—
shipment.deliveredshipment—
shipment.tracking_setshipmentpreviousTrackingNumber
shipment.ready_for_pickupshipment—
product.createdproduct—
product.updatedproduct— (stock-only changes raise product.stock_changed instead)
product.deletedproduct (deleted: true)—
product.stock_changedstock change: productId, combinationId, sku, warehouseId, oldQuantity, newQuantity, adjustment, reason—
product.price_changedproductprice, oldPrice, productCost: {from, to}, only the ones that changed
customer.registeredcustomer—
customer.activatedcustomer—
customer.updatedcustomer—
customer.deleted{id, deleted: true} only—
newsletter.subscribedsubscription: id, guid, email, active, storeId, createdOnUtc—
newsletter.unsubscribedsubscription—
review.approvedreview—
test.pinga 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-After header 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's updatedOnUtc) 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

JavaScript
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

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

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#

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

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://. Plain http:// is accepted only for localhost, 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:

C#
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:

C#
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.