Authentication

HMAC-SHA256 on the webhooks YUMBI Gateway sends you, OAuth 2.0 on the status updates you send YUMBI Gateway.

Authentication

A delivery integration authenticates in both directions, with a different scheme each way.

DirectionSchemeSecret
YUMBI Gateway → you (webhook events)HMAC-SHA256 signature headersYour API key, issued by YUMBI Gateway
You → YUMBI Gateway (status updates)OAuth 2.0 client credentialsYour client id + secret, issued by YUMBI Gateway

The two secrets are unrelated. Losing one does not compromise the other, and either can be rotated on its own.

Verifying the webhooks we send you

Every request we make to your endpoint carries three headers:

HeaderValue
X-Client-IDYour client identifier. Constant for your integration — use it to confirm the traffic is yours, and to select the right API key if you serve more than one YUMBI Gateway environment.
X-TimestampUnix seconds. The same value as event_timestamp in the body.
X-HMACLowercase hex HMAC-SHA256 of the signing string, keyed with your API key.

The signing string

The URL path, the raw request body, and the timestamp, concatenated with no separator:

path + raw_body + timestamp
  • path is the path component of the URL you registered with us. For https://couriers.example.com/yumbi/webhooks that is /yumbi/webhooks — no scheme, no host, no query string.
  • raw_body is the request body exactly as it arrived, byte for byte.
  • timestamp is the X-Timestamp value as its decimal string.
🚧

Verify the raw bytes, never a re-serialised body

We sign the exact bytes we put on the wire. If you parse the JSON and re-serialise it before hashing, key order or whitespace can differ and the signature will not match. Capture the raw body in your framework before any body parser touches it — in Express that means the verify hook on express.json(); in ASP.NET Core, EnableBuffering; in Rails, request.raw_post.

Reference implementations

import { createHmac, timingSafeEqual } from "node:crypto";

// rawBody must be the unparsed request body (Buffer or string).
export function verifyYumbiWebhook(req, rawBody, apiKey) {
    const signature = req.header("X-HMAC");
    const timestamp = req.header("X-Timestamp");
    if (!signature || !timestamp) return false;

    // Reject replays before spending time on the hash.
    const skew = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
    if (!Number.isFinite(skew) || skew > 300) return false;

    const expected = createHmac("sha256", apiKey)
        .update(`${req.path}${rawBody}${timestamp}`)
        .digest("hex");

    const a = Buffer.from(expected, "utf8");
    const b = Buffer.from(signature, "utf8");
    return a.length === b.length && timingSafeEqual(a, b);
}
public static bool VerifyYumbiWebhook(string path, string rawBody, string timestamp,
                                      string signature, string apiKey)
{
    if (string.IsNullOrEmpty(signature) || !long.TryParse(timestamp, out var ts))
        return false;

    var skew = Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts);
    if (skew > 300) return false;

    using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(apiKey));
    var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes($"{path}{rawBody}{timestamp}"));
    var expected = Convert.ToHexString(hash).ToLowerInvariant();

    return CryptographicOperations.FixedTimeEquals(
        Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(signature));
}
def verify_yumbi_webhook(request, raw_body, api_key)
  signature = request.headers["X-HMAC"].to_s
  timestamp = request.headers["X-Timestamp"].to_s
  return false if signature.empty? || timestamp.empty?
  return false if (Time.now.to_i - timestamp.to_i).abs > 300

  expected = OpenSSL::HMAC.hexdigest("SHA256", api_key, "#{request.path}#{raw_body}#{timestamp}")
  ActiveSupport::SecurityUtils.secure_compare(expected, signature)
end

Rules to hold to

  • Compare in constant time. A plain string comparison leaks the signature a byte at a time.
  • Enforce a replay window. Five minutes (300 seconds) is what YUMBI Gateway's own guards use in the opposite direction. Reject anything outside it with a 401.
  • Reject rather than ignore. Answer 401 on a bad or missing signature. A 200 on unauthenticated traffic means we never learn the integration is misconfigured.
  • Log every rejection with the path, the timestamp, the clock skew you measured, and a truncated prefix of the signature. Because a guard runs before your handler, an unsigned or wrongly signed push is otherwise indistinguishable from one that never arrived. Never log the secret or the full signature.

Authenticating your calls to YUMBI Gateway

Status updates use OAuth 2.0 client credentials. Request a token, then send it as a bearer token.

The token endpoint differs per environment, and so do your credentials:

EnvironmentOAuth token endpoint
Staginghttps://flexible-diamond-18-staging.authkit.app/oauth2/token
Productionhttps://auth.yumbi.com/oauth2/token

The examples below use production.

POST /oauth2/token HTTP/1.1
Host: auth.yumbi.com
Content-Type: application/x-www-form-urlencoded

client_id=CLIENT_ID&client_secret=CLIENT_SECRET&grant_type=client_credentials
HTTP/1.1 200 OK

{
  "access_token": "eyJraWQiOiJYRE...",
  "expires_in": 3600,
  "token_type": "Bearer"
}
POST /api/v1/deliveries/{order_guid}/status_update HTTP/1.1
Host: gateway.yumbi.com
Authorization: Bearer eyJraWQiOiJYRE...
Content-Type: application/json

Cache the token for its lifetime and reuse it across requests. Minting a token per status update will rate-limit you, and a busy provider pushes location pings continuously. Refresh shortly before expires_in elapses, and re-mint on a 401 — a token can expire in flight.

The Gateway resolves your identity from the token's client id claim, and every delivery is checked against it. A delivery assigned to another provider reads as not found, never as forbidden, so the response never confirms whether someone else's order exists.

🚧

HMAC does not work inbound

Inbound Gateway calls are OAuth only, deliberately. HMAC is reserved for the outbound direction. A status update signed with X-HMAC and X-Client-ID but carrying no Authorization header is rejected with a 401.

Per-brand signing secrets

If the webhooks you push carry a signing secret that is scoped per brand rather than per integration — one account per brand — tell us during onboarding. In that arrangement you include brand_guid in the status update body naming the brand whose secret authenticated the call, and the Gateway checks that the delivery belongs to that brand. Without it, one brand's account on your platform could move another brand's delivery, because both brands sit behind the same provider identity.

Omit brand_guid entirely if your signing is per integration rather than per brand. The provider identity is then the whole check.

Rotating secrets

Both secrets are rotated by YUMBI Gateway on request. Because HMAC verification is stateless there is no overlap window built into the protocol — if you need one, accept either the old or the new API key for the duration of the changeover and tell us when you are ready to drop the old one.


Did this page help you?