Skip to Content
APIWebhooks

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:

PartExact format
MethodPOST
Content typeapplication/json
X-TimestampUnix time in whole seconds, encoded as decimal ASCII, for example 1700000000
X-Signature64 lowercase hexadecimal characters: the HMAC-SHA256 digest, without a sha256= prefix
BodyUTF-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:

  1. Read X-Timestamp and X-Signature. Reject the request if either header is missing.
  2. Parse X-Timestamp as Unix time in whole seconds. Reject malformed values.
  3. 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.
  4. Read the exact request body as bytes. Do not parse the JSON yet.
  5. Calculate HMAC-SHA256 using the full webhook secret and the byte sequence X-Timestamp + "." + raw_request_body.
  6. Encode the 32-byte digest as 64 lowercase hexadecimal characters.
  7. Compare the calculated and received signatures with a constant-time comparison.
  8. Only after successful verification, parse the JSON and validate the event schema.
  9. Deduplicate deliveries by event_id, enqueue any slow processing, and return a 2xx response 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 = 96e6d0b42af8531b85dda1fd34905ac41857174186c44aeddd2610f7e10d5da3

The 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 }
FieldMeaning
event_idUnique UUID for this logical event. Use it as the idempotency key across retries.
event_typeEvent name used for routing.
occurred_atTime when the event occurred, encoded as UTC ISO 8601 with a Z suffix.
event_timestamp_nsThe event time as Unix nanoseconds. This is event metadata and is not the signing timestamp.
schema_versionInteger payload schema version.
partner_user_idYour identifier for the affected user.
dataObject 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_started
  • subscription.paid_started
  • subscription.stopped
  • workout.completed
  • user.deleted
  • test.fitness_result
  • test.flexibility_result
  • body_composition.measurement
  • promo_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-2xx response 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.
Last updated on