Webhooks
Zing sends an HTTP POST request to your configured webhook URL when a partner event occurs. Verify every request before parsing or processing its JSON body.
Signing contract
Zing signs each delivery with the HMAC secret assigned to that webhook destination. You must store the complete secret value and use it as the HMAC key. The secret is shared only between Zing and your service; it is never included in a webhook request.
Each request contains:
| Part | Exact format |
|---|---|
| Method | POST |
| Content type | application/json |
X-Timestamp | Unix time in whole seconds, encoded as decimal ASCII, for example 1700000000 |
X-Signature | 64 lowercase hexadecimal characters: the HMAC-SHA256 digest, without a sha256= prefix |
| Body | UTF-8 JSON bytes sent by Zing |
The signature is calculated as follows:
key = UTF8(webhook_secret)
message = UTF8(X-Timestamp) || UTF8(".") || raw_request_body
digest = HMAC-SHA256(key, message)
X-Signature = lowercase_hex(digest)Here, || means byte concatenation. The period is one byte with hexadecimal value 2e.
The exact bytes received in the HTTP body are authoritative. Read the body as bytes and verify the signature before JSON parsing. Do not parse and serialize the JSON again, change whitespace, sort keys, normalize Unicode, add a newline, or decompress/compress the body before verification. Zing currently sends the body without content encoding.
HTTP header names are case-insensitive, but the value of X-Timestamp must be used exactly as received when constructing the signed message.
Verification algorithm
For every request:
- Read
X-TimestampandX-Signature. Reject the request if either header is missing. - Parse
X-Timestampas Unix time in whole seconds. Reject malformed values. - Check that the timestamp is within your replay-protection window. We recommend 300 seconds in either direction from your server time. Keep your server clock synchronized.
- Read the exact request body as bytes. Do not parse the JSON yet.
- Calculate HMAC-SHA256 using the full webhook secret and the byte sequence
X-Timestamp + "." + raw_request_body. - Encode the 32-byte digest as 64 lowercase hexadecimal characters.
- Compare the calculated and received signatures with a constant-time comparison.
- Only after successful verification, parse the JSON and validate the event schema.
- Deduplicate deliveries by
event_id, enqueue any slow processing, and return a2xxresponse quickly.
Return a non-2xx response for a missing header, malformed timestamp, stale timestamp, or invalid signature. Zing treats any non-2xx response or timeout as a failed delivery and may retry it. Return 2xx for an already processed event_id so that a duplicate is considered successfully handled.
Test vector
Use this fixed input to test your implementation independently of an HTTP framework:
webhook_secret = test_secret
X-Timestamp = 1700000000
raw body = {"x":1}
signed bytes = 1700000000.{"x":1}
X-Signature = 96e6d0b42af8531b85dda1fd34905ac41857174186c44aeddd2610f7e10d5da3The raw body in this vector contains no trailing newline. The timestamp is intentionally fixed and old: use this vector to test the HMAC calculation separately from the timestamp freshness check.
Python example
Pass the raw request body as bytes. For example, in FastAPI use raw_body = await request.body().
import hashlib
import hmac
import time
MAX_AGE_SECONDS = 300
def verify_zing_webhook(
raw_body: bytes,
timestamp_header: str | None,
signature_header: str | None,
webhook_secret: str,
) -> bool:
if timestamp_header is None or signature_header is None:
return False
if not timestamp_header.isascii() or not timestamp_header.isdigit():
return False
try:
timestamp = int(timestamp_header)
except ValueError:
return False
if abs(int(time.time()) - timestamp) > MAX_AGE_SECONDS:
return False
if len(signature_header) != 64:
return False
signed_message = timestamp_header.encode("utf-8") + b"." + raw_body
expected = hmac.new(
webhook_secret.encode("utf-8"),
signed_message,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature_header)Java example
Pass the body to this method as the original byte[]. In Spring, bind the request body as @RequestBody byte[] rawBody rather than converting it to an object first. This example uses Java 17 or later.
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.HexFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public final class ZingWebhookVerifier {
private static final long MAX_AGE_SECONDS = 300;
public static boolean verify(
byte[] rawBody,
String timestampHeader,
String signatureHeader,
String webhookSecret) {
try {
if (timestampHeader == null || signatureHeader == null
|| !timestampHeader.matches("[0-9]+")
|| !signatureHeader.matches("[0-9a-f]{64}")) {
return false;
}
long timestamp = Long.parseLong(timestampHeader);
long now = Instant.now().getEpochSecond();
if (Math.abs(now - timestamp) > MAX_AGE_SECONDS) {
return false;
}
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(
webhookSecret.getBytes(StandardCharsets.UTF_8),
"HmacSHA256"));
mac.update(timestampHeader.getBytes(StandardCharsets.UTF_8));
mac.update((byte) '.');
byte[] expected = mac.doFinal(rawBody);
byte[] received = HexFormat.of().parseHex(signatureHeader);
return received.length == expected.length
&& MessageDigest.isEqual(expected, received);
} catch (IllegalArgumentException | GeneralSecurityException error) {
return false;
}
}
}Go example
Read the body once with io.ReadAll(request.Body) and pass the returned byte slice to the verifier.
package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"regexp"
"strconv"
"time"
)
const maxAgeSeconds int64 = 300
var timestampPattern = regexp.MustCompile(`^[0-9]+$`)
var signaturePattern = regexp.MustCompile(`^[0-9a-f]{64}$`)
func VerifyZingWebhook(
rawBody []byte,
timestampHeader string,
signatureHeader string,
webhookSecret string,
) bool {
if !timestampPattern.MatchString(timestampHeader) ||
!signaturePattern.MatchString(signatureHeader) {
return false
}
timestamp, err := strconv.ParseInt(timestampHeader, 10, 64)
if err != nil {
return false
}
age := time.Now().Unix() - timestamp
if age < -maxAgeSeconds || age > maxAgeSeconds {
return false
}
received, err := hex.DecodeString(signatureHeader)
if err != nil || len(received) != sha256.Size {
return false
}
mac := hmac.New(sha256.New, []byte(webhookSecret))
mac.Write([]byte(timestampHeader))
mac.Write([]byte("."))
mac.Write(rawBody)
expected := mac.Sum(nil)
return hmac.Equal(expected, received)
}TypeScript example
This example uses Node.js. The rawBody argument must be the original Buffer, not the result of JSON.stringify(request.body).
import { createHmac, timingSafeEqual } from "node:crypto";
const MAX_AGE_SECONDS = 300;
export function verifyZingWebhook(
rawBody: Buffer,
timestampHeader: string | undefined,
signatureHeader: string | undefined,
webhookSecret: string,
): boolean {
if (
timestampHeader === undefined
|| signatureHeader === undefined
|| !/^[0-9]+$/.test(timestampHeader)
) {
return false;
}
const timestamp = Number(timestampHeader);
const now = Math.floor(Date.now() / 1000);
if (!Number.isSafeInteger(timestamp) || Math.abs(now - timestamp) > MAX_AGE_SECONDS) {
return false;
}
if (!/^[0-9a-f]{64}$/.test(signatureHeader)) {
return false;
}
const expected = createHmac("sha256", Buffer.from(webhookSecret, "utf8"))
.update(Buffer.from(`${timestampHeader}.`, "utf8"))
.update(rawBody)
.digest();
const received = Buffer.from(signatureHeader, "hex");
return timingSafeEqual(expected, received);
}With Express, configure the route with express.raw({ type: "application/json" }). If a JSON body parser runs first, the original bytes may be lost and verification can fail.
Webhook payload
After signature verification, parse the body as JSON. Every delivery has this envelope:
{
"data": {
"workout_id": "f8bc0e12-5581-46a1-95aa-51b733d3da0b"
},
"event_id": "01963b5a-0f20-7b6c-8d5e-123456789abc",
"event_timestamp_ns": 1776420930000000000,
"event_type": "workout.completed",
"occurred_at": "2026-04-17T10:15:30Z",
"partner_user_id": "partner-user-456",
"schema_version": 1
}| Field | Meaning |
|---|---|
event_id | Unique UUID for this logical event. Use it as the idempotency key across retries. |
event_type | Event name used for routing. |
occurred_at | Time when the event occurred, encoded as UTC ISO 8601 with a Z suffix. |
event_timestamp_ns | The event time as Unix nanoseconds. This is event metadata and is not the signing timestamp. |
schema_version | Integer payload schema version. |
partner_user_id | Your identifier for the affected user. |
data | Object with fields specific to event_type. |
Do not confuse event_timestamp_ns in the JSON body with X-Timestamp. X-Timestamp is generated for each delivery attempt and is the value included in the HMAC input. A retry can therefore have the same body and event_id but a new X-Timestamp and X-Signature.
Current event types are:
subscription.trial_startedsubscription.paid_startedsubscription.stoppedworkout.completeduser.deletedtest.fitness_resulttest.flexibility_resultbody_composition.measurementpromo_code.redeemed
Use the Pull API when you need the current full resource state. Do not assume that webhook deliveries are ordered.
Secret management and rotation
- Each custom webhook destination is configured with an HMAC secret that belongs to the same partner. Use the secret provided for that destination; do not assume that another destination uses the same value. Use the complete secret exactly as provided, without trimming, hashing, or Base64-decoding it unless Zing explicitly provides an encoded value.
- Store the secret in a secret manager or environment variable. Never put it in source code or logs.
- Do not log the raw signature input if webhook data is sensitive.
- Secret rotation is coordinated with Zing. There is currently no key identifier in the webhook headers, so agree on a transition procedure before replacing the secret.
Delivery behavior
- Zing sends deliveries asynchronously. The action that produced the event does not wait for your webhook processing.
- Any non-
2xxresponse or timeout is a failed attempt and can be retried for up to 24 hours. - Retries keep the same event body and
event_id, but signing headers can change for each attempt. - Process events idempotently and do not rely on delivery order.
- You can inspect delivery status with GET
/webhooks/deliveries.