Ozow Hub
On this page5 sections
Build with AI 5 packages

A build package is every page for one task, with the API operations they use. Copy the prompt into a coding assistant, or hand it the package itself: slim links to each page, full inlines all of them in one document.

Filter
  • Take a paymentEverything needed to take a payment end to end with One API, from credentials through the hosted page to the webhook that confirms it, and the test cases that prove each outcome before you go live.
    View package
  • Take a recurring paymentEverything needed to collect from a customer on a schedule with One API, from the consent the customer gives once through to each collection and the webhook that reports it.
    View package
  • Migrate a payin from the Payments API to One APIEverything needed to move an existing redirect payin onto One API, with the legacy guide and its One API counterpart side by side.
    View package

Your webhookWebhook A URL of yours that Ozow calls when something happens, rather than you polling to find out. The call carries no credential of yours and arrives at a public URL, so authenticate it before acting on it: a hash field on the Payments API, a Svix signature on One API. URL is public. Anyone who finds it can post a transaction.complete to it claiming a payment succeeded, and the only thing separating that from a real delivery is the signature. Check it before you read a single field of the body.

This page covers the One API webhook signature. The Payments API notification is authenticated differently, with a Hash field over the payload: see the hash calculator for that one.

Important

Verify against the exact bytes you received. The signature covers the body byte for byte, and a framework that parses the request as JSON and hands you the object has already thrown those bytes away: serialising it back reorders keys and changes whitespace, and nothing matches. Reach for the raw body explicitly. In Express that is express.raw(), in ASP.NET Core EnableBuffering and reading the stream yourself, in Flask request.get_data(), in Laravel $request->getContent().

Use the library where you can

Ozow delivers webhooks through Svix, and Svix publishes a verification library for most languages. It gets the constant-time comparison, the replay window and the signature list right, and it is the shortest path to a correct handler.

// svix 2.x. `verify` throws on a bad signature and returns nothing.
import { Webhook } from "svix";

const webhook = new Webhook(process.env.OZOW_WEBHOOK_SECRET);

webhook.verify(rawBody, {
  "svix-id": headers["svix-id"],
  "svix-timestamp": headers["svix-timestamp"],
  "svix-signature": headers["svix-signature"],
});
const event = JSON.parse(rawBody);

Pin the major version, and read its signature before you upgrade

verify returned the parsed body on svix 1.x and returns nothing on 2.x. A handler that keeps const event = webhook.verify(...) across that upgrade reads undefined, fails after it has already replied 200, and looks from our side like a delivery that succeeded.

The rest of this page is what that library does, for when you would rather not add one.

The algorithm

Every delivery carries three headers:

Header What it holds
svix-id The message identifier, unchanged across retries of the same event
svix-timestamp When the delivery was signed, in seconds since the epoch
svix-signature One or more signatures, space separated, each written v1,<base64>

Five steps, and all five are load bearing:

  1. Require all three headers. A delivery missing any of them is not one of ours.
  2. Check the timestamp is within five minutes of now, in either direction. Without this, a signature captured once stays valid forever and a recorded delivery can be replayed at will.
  3. Turn the secret into key bytes. The secret from Get Webhook Secret arrives as whsec_ followed by Base64Base64 A way of writing binary data using ordinary text characters, so it can travel inside JSON or a URL. It is an encoding, not encryption: anyone can decode it.Wikipedia. Strip the prefix and Base64-decode the rest. The key is those raw bytes, not the string.
  4. Build the signed string and take its HMACHMAC A hash of a message combined with a secret key, so the result proves both that the message is unchanged and that the sender held the key. A plain hash proves only the first: anybody can compute one. This is what makes a webhook signature worth checking.RFC 2104. The string is the message id, the timestamp and the raw body joined by full stops: {svix-id}.{svix-timestamp}.{body}. HMAC-SHA256 it with the key bytes and Base64-encode the digest.
  5. Compare against every v1 signature in the header, with a constant-time comparison. The header can carry more than one while a secret is being rotated, and a match on any of them is a pass. Ignore any entry whose version is not v1.

A verifier

Each of these returns true only if the delivery is genuine, current and intact. None of them needs a dependency beyond the standard library.

using System.Security.Cryptography;
using System.Text;

const int ToleranceSeconds = 300;

static bool IsFromOzow(
    string secret,
    string svixId,
    string svixTimestamp,
    string svixSignature,
    string rawBody
)
{
    if (!long.TryParse(svixTimestamp, out var sent))
        return false;
    if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - sent) > ToleranceSeconds)
        return false;

    var key = Convert.FromBase64String(
        secret.StartsWith("whsec_", StringComparison.Ordinal) ? secret["whsec_".Length..] : secret
    );

    using var hmac = new HMACSHA256(key);
    var digest = hmac.ComputeHash(Encoding.UTF8.GetBytes($"{svixId}.{sent}.{rawBody}"));
    var expected = Encoding.UTF8.GetBytes(Convert.ToBase64String(digest));

    foreach (var candidate in svixSignature.Split(' '))
    {
        var parts = candidate.Split(',', 2);
        if (parts.Length != 2 || parts[0] != "v1")
            continue;
        // Returns false on a length mismatch rather than throwing, and takes the
        // same time whether the first byte differs or the last one does.
        if (CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(parts[1]), expected))
            return true;
    }

    return false;
}

What goes wrong

The first two announce themselves the moment you test. The last three pass every test you are likely to write and fail in production, which is why they are worth reading twice.

Mistake What happens
The secret used as a string The key is the Base64-decoded bytes after whsec_. Using the characters gives a digest that never matches anything.
The body parsed before it is verified Re-serialising changes the bytes. Nothing matches, on every delivery.
No timestamp check Every signature stays valid forever. One captured delivery can be replayed for as long as the secret lives.
== instead of a constant-time compare The comparison returns as soon as two bytes differ, and how long it took says how much of a guess was right.
Only the first signature checked svix-signature carries both the old and the new signature while a secret is rotated. A verifier that reads one of them starts rejecting real deliveries mid-rotation.

Check your verifier

Start offline. Svix publishes a signature you can check against without sending anything, which separates a wrong implementation from a wrong endpoint before either can confuse the other:

secret     whsec_plJ3nmyCDGBKInavdOK15jsl
body       {"event_type":"ping","data":{"success":true}}
svix-id    msg_loFOjxBNrRLzqYUf
timestamp  1731705121
signature  v1,rAvfW3dJ/X/qxhsaXPOyyCGmRKsaKWcsNccKXlIktD0=

Feed those five values to your verifier and it must produce that signature. The timestamp is from 2024, so a verifier that checks the replay window rejects the delivery even when the signature is right: check the signature it computed rather than the answer it returned, or hold the clock at 1731705121 for the test.

Then send yourself a real delivery and confirm all four of these, in this order:

  1. An untouched delivery passes. Anything else and the rest of the list means nothing.
  2. One changed byte of the body fails. Change a digit of the amount and replay it.
  3. One changed character of svix-signature fails.
  4. The same delivery replayed an hour later fails. If it passes, step 2 of the algorithm is missing.

A verifier that rejects a delivery must log it and alert. A signature that does not match is either a bug of ours or somebody probing your endpoint, and both are worth a person looking at them. Never discard one quietly.

In the API reference

3 entries

Last updated