Skip to content

Optional: callbacks you implement

This step is optional. Steps 1 to 10 already make a complete integration. The three endpoints here improve how your channel works inside Zentail:

  • Initiate ingestion lets Zentail ask your channel for the listings already live on it, so a seller can bring them into their catalog.
  • Storefront status lets Zentail show a seller whether their connection to your channel is healthy.
  • Delete configuration lets Zentail tell your channel when a seller disconnects a storefront.

Support for these callbacks in Zentail is being built. You can implement them now, and Zentail will use them once that support is in place. When it does, it will sign every call, so you can tell a real call from Zentail from anyone else’s; see Verify that Zentail sent the call.

Every other step is a call you make to Zentail. This one runs the other way: you build three endpoints on your host, for Zentail to call. Together they are the SalesChannelService.

That is what the outbound badge on each reference page below means, and it is why their copyable requests render against https://{your-host} rather than https://api.zentail.com. Two of the three sit under the same /v2/storefront/ prefix as endpoints Zentail serves, so the path alone does not tell you which way a call runs — the badge does.

Three endpoints on your host, for Zentail to call: one to start an ingestion, one to report a storefront’s health, and one to disconnect a storefront. Each one checks that the call really came from Zentail before it acts.

  • When you choose to implement these callbacks, send Zentail the base URL of your host. Email it to [email protected], with your application name and client ID.
  • Serve the base URL over HTTPS. The signature below proves who sent a call, but it does not hide what the call says.
  • Zentail will issue your application a callback signing secret, separate from your OAuth client secret. Keep it on your server, and never send it anywhere.

Without a check, anyone who learns your URL can call these endpoints, and delete configuration will disconnect a seller’s storefront. So Zentail will sign every call it makes to your host, and you check the signature before you act on the call.

Zentail does not call these endpoints yet. Once it starts, it will sign every call as described here. Build the check now, so your endpoints are safe from the first call.

Every call carries two headers:

  • X-Zentail-Timestamp is the Unix time, in seconds, when Zentail signed the call. For example, 1790000000.
  • X-Zentail-Signature is v1= followed by the signature: an HMAC-SHA256 (a keyed hash) of the signing string below, keyed with your signing secret, written as lowercase hex.

When Zentail replaces your secret, it signs with the old and the new secret for a while. The header then holds one v1= value for each, separated by a comma: v1=<signature>,v1=<signature>. The call is genuine if any one of them matches.

The signing string is four parts, joined by a single newline character (\n), in this order:

  1. the value of X-Zentail-Timestamp, exactly as sent
  2. the HTTP method, in upper case: POST, GET or DELETE
  3. the path of the request, and its query string if it has one, exactly as Zentail sent it. This includes any path that is part of the base URL you registered.
  4. the raw body of the request, byte for byte, before you parse it

Status and delete configuration send no body. Their signing string still has all four parts, so it ends with the newline after the path.

The method and the path are part of the string because those two calls name the storefront only in the path. Without them, someone could copy a signed call for one storefront and replay it against another.

  1. Read both headers. If either is missing, reject the call.
  2. Compare the timestamp with your own clock. If it is more than 300 seconds (5 minutes) away, in either direction, reject the call. This stops an old call being replayed later.
  3. Build the signing string from the raw body, before any JSON parsing. A parser that reorders keys or changes spacing produces different bytes, and the signature will not match.
  4. Compute the HMAC-SHA256 of the signing string, using your signing secret’s exact characters as the key. Write it as lowercase hex.
  5. Compare it with each v1= value in X-Zentail-Signature, using a constant-time comparison, such as crypto.timingSafeEqual in Node.js or hmac.compare_digest in Python. If none matches, reject the call.
  6. Only now parse the body and handle the call.

Reject a call with HTTP 401 and a google.rpc.Status body with code 16 (UNAUTHENTICATED):

{
"code": 16,
"message": "The request signature is not valid.",
"details": []
}

The timestamp check limits replays to 5 minutes, but it does not stop a copied call being re-sent inside that window. So make each callback safe to receive twice.

With the signing secret example-signing-secret, Zentail sends this initiate call:

POST https://{your-host}/ingestion/initiate
X-Zentail-Timestamp: 1790000000
X-Zentail-Signature: v1=f843213bc8516c08a1b357eb3dbe8ea2b9891a7b644c5d99b16cad5534adc3e6
{"storefrontId":"12345","skus":["TSHIRT-RED-M"],"autoApplyIngestionPlan":true}

Its signing string is these three lines and the body, joined by newlines, with no newline at the end:

1790000000
POST
/ingestion/initiate
{"storefrontId":"12345","skus":["TSHIRT-RED-M"],"autoApplyIngestionPlan":true}

For DELETE https://{your-host}/v2/storefront/configuration/12345 at the same time, the signing string is 1790000000\nDELETE\n/v2/storefront/configuration/12345\n, and the signature is v1=2dd1353d17ae5e5bd29d98fcb23d150c8798db4990e7679dc880d0a85d189d79.

In Node.js, the check looks like this:

import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody is a Buffer of the request body exactly as received.
function isFromZentail(req, rawBody, secret, now = Date.now() / 1000) {
const timestamp = req.headers["x-zentail-timestamp"];
const header = req.headers["x-zentail-signature"];
if (!timestamp || !header) return false;
const ts = Number(timestamp);
if (!Number.isFinite(ts) || Math.abs(now - ts) > 300) return false;
const signed = Buffer.concat([
Buffer.from(`${timestamp}\n${req.method}\n${req.originalUrl}\n`),
rawBody,
]);
const expected = createHmac("sha256", secret).update(signed).digest();
return header.split(",").some((part) => {
const given = Buffer.from(part.trim().replace(/^v1=/, ""), "hex");
return given.length === expected.length && timingSafeEqual(given, expected);
});
}
POST https://{your-host}/ingestion/initiate

Zentail uses this to ask for the listings already live on your channel for one storefront. The body carries the storefrontId, the skus to ingest, and autoApplyIngestionPlan. What an empty skus list asks for is not defined yet.

In response, push those listings into Zentail. Whether you reply to this call before you push them, or only after the end call, is not defined yet. From them, Zentail builds an ingestion plan: the set of changes that brings your channel’s listings into the seller’s Zentail catalog. autoApplyIngestionPlan says whether Zentail applies that plan on its own once you finish. When it is false, Zentail builds the plan but does not apply it automatically.

Push the listings in three calls:

  1. POST /v2/storefront/listing/ingestion/begin, with expectedNumListings and the autoApplyIngestionPlan value you received.
  2. POST /v2/storefront/listing/ingestion/request, once per listing, with its id, productTypeId, pivotAttributes, attributes and variants.
  3. POST /v2/storefront/listing/ingestion/end, with actualNumListings, once every listing is requested. Zentail then builds the ingestion plan, and applies it if autoApplyIngestionPlan was true.

Each request body describes one listing:

{
"id": "TSHIRT-RED",
"productTypeId": "mens-tshirts",
"pivotAttributes": ["size"],
"attributes": [{ "id": "brand", "textValue": "Acme" }],
"variants": [
{
"sku": "TSHIRT-RED-M",
"price": { "currencyCode": "USD", "units": "12", "nanos": 340000000 },
"attributes": [{ "id": "size", "textValue": "M" }]
}
]
}
  • id is your own identifier for the listing, not a Zentail ID. Zentail uses it as the listing’s product group label.
  • price is a google.type.Money: whole units as a string, plus nanos (billionths of a unit). This is not the moneyValue shape the pricing attributes use. The example is $12.34.

The begin and end bodies are small:

{ "expectedNumListings": "120", "autoApplyIngestionPlan": true }
{ "actualNumListings": "118" }

Both counts are 64-bit integers, so they are JSON strings.

A successful reply to Zentail’s initiate call is HTTP 200 with an empty object:

{}
GET https://{your-host}/v2/storefront/status/{storefrontId}

Zentail uses this to read a storefront’s health, as a list of diagnostic checks. Sellers see the result when they are working out whether their connection to your channel is healthy, so return checks that would help someone debug it:

{
"checks": [
{
"name": "Credentials",
"state": "CHECK_STATE_PASS",
"stateShortDescription": "Connected",
"details": "The seller's channel credentials were accepted at 14:02 UTC."
}
]
}

Set every check’s state to CHECK_STATE_PASS, CHECK_STATE_FAIL or CHECK_STATE_WARNING. The enum has a fourth value, CHECK_STATE_UNSPECIFIED, and it is the default: a check sent without a state counts as unspecified. How Zentail shows an unspecified check is not defined yet.

DELETE https://{your-host}/v2/storefront/configuration/{storefrontId}

Zentail uses this to tell your channel to delete — that is, deactivate — the storefront’s integration configuration. It is sent when a storefront is disconnected in Zentail.

A successful reply is HTTP 200 with an empty object:

{}

When a callback fails, reply with an HTTP status other than 200 and a google.rpc.Status body. It is the same body Zentail’s own routes send on an error, apart from the v1 routes; see Errors for what each field means.

{
"code": 13,
"message": "The channel's API did not answer.",
"details": []
}

Which HTTP status to send with each code is not defined yet for these callbacks.

API changelog · Built 0c509dd3