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.
What you’ll build
Section titled “What you’ll build”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.
Before you start
Section titled “Before you start”- 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.
Verify that Zentail sent the call
Section titled “Verify that Zentail sent the call”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.
The two headers
Section titled “The two headers”Every call carries two headers:
X-Zentail-Timestampis the Unix time, in seconds, when Zentail signed the call. For example,1790000000.X-Zentail-Signatureisv1=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
Section titled “The signing string”The signing string is four parts, joined by a single newline character (\n), in this
order:
- the value of
X-Zentail-Timestamp, exactly as sent - the HTTP method, in upper case:
POST,GETorDELETE - 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.
- 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.
How to check a call
Section titled “How to check a call”- Read both headers. If either is missing, reject the call.
- 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.
- 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.
- Compute the HMAC-SHA256 of the signing string, using your signing secret’s exact characters as the key. Write it as lowercase hex.
- Compare it with each
v1=value inX-Zentail-Signature, using a constant-time comparison, such ascrypto.timingSafeEqualin Node.js orhmac.compare_digestin Python. If none matches, reject the call. - 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.
A worked example
Section titled “A worked example”With the signing secret example-signing-secret, Zentail sends this initiate call:
POST https://{your-host}/ingestion/initiateX-Zentail-Timestamp: 1790000000X-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:
1790000000POST/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); });}1. Initiate ingestion
Section titled “1. Initiate ingestion”POST https://{your-host}/ingestion/initiateZentail 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:
POST /v2/storefront/listing/ingestion/begin, withexpectedNumListingsand theautoApplyIngestionPlanvalue you received.POST /v2/storefront/listing/ingestion/request, once per listing, with itsid,productTypeId,pivotAttributes,attributesandvariants.POST /v2/storefront/listing/ingestion/end, withactualNumListings, once every listing is requested. Zentail then builds the ingestion plan, and applies it ifautoApplyIngestionPlanwastrue.
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" }] } ]}idis your own identifier for the listing, not a Zentail ID. Zentail uses it as the listing’s product group label.priceis agoogle.type.Money: wholeunitsas a string, plusnanos(billionths of a unit). This is not themoneyValueshape 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:
{}2. Storefront status
Section titled “2. Storefront status”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.
3. Delete configuration
Section titled “3. Delete configuration”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:
{}4. Reply with an error
Section titled “4. Reply with an error”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.
See also
Section titled “See also”API changelog · Built 0c509dd3