View as Markdown

llms.txt

This guide isn't available right now

This instance couldn't load its guide catalog. The guide returns as soon as the catalog is readable again.

Back to the guides

No such guide

This instance publishes no guide under that address. It may have been renamed, or it may belong to a feature this installation hasn't enabled.

Back to the guides

That guide is part of the product documentation

This guide is written for someone operating WinkPG through its screens rather than integrating against it, so it lives in the application's own help section instead of here. Sign in to WinkPG and open Help to read it.

Back to the guides

Guides Quickstart

Quickstart: Webhooks

Stand up an endpoint that verifies a WinkPG delivery signature, register it, and receive your first event.

WinkPG posts platform events to an HTTPS endpoint you control: a payment completing, an invoice changing state, a settlement landing. This is how your server learns what happened without polling, and it's the authoritative record for every payment collected outside your own code.

You need an HTTPS endpoint that's reachable from the internet, and an API key.

1. Write the receiver

Two checks matter and both run before you trust anything in the body. Verify the signature, and reject a delivery whose timestamp is outside a few minutes of now.

The signature is HMAC-SHA256 over the timestamp header, a literal ., then the raw request body, keyed with the destination secret. Sign the bytes you received, never a re-serialized copy: a round trip through your JSON library reorders keys and changes whitespace, and the digest no longer matches.

app.MapPost("/webhooks/payments", async (HttpRequest request) =>
{
    using var buffer = new MemoryStream();
    await request.Body.CopyToAsync(buffer);
    var rawBody = buffer.ToArray();

    var timestamp = request.Headers["X-WinkPG-Timestamp"].ToString();
    var signature = request.Headers["X-WinkPG-Signature"].ToString();

    // Freshness first. The timestamp is part of the signed input, so a delivery without a
    // usable one cannot have been signed by us, and a signature alone would let anyone
    // replay a captured delivery forever.
    if (!IsFresh(timestamp, DateTimeOffset.UtcNow, TimeSpan.FromMinutes(5)))
    {
        return Results.Unauthorized();
    }

    var expected = HMACSHA256.HashData(
        Encoding.UTF8.GetBytes(WEBHOOK_SECRET),
        Encoding.UTF8.GetBytes(timestamp + ".").Concat(rawBody).ToArray());

    // The header carries one entry per secret the endpoint is signed with: one normally, and
    // two while its secret is being rotated. Accept a match on any entry, or your receiver
    // rejects every delivery for the length of the next rotation.
    if (!AnyEntryMatches(signature, expected))
    {
        return Results.Unauthorized();
    }

    var deliveryId = request.Headers["X-WinkPG-Delivery-Id"].ToString();

    // Refuse a blank id rather than deduplicating on an empty key, which would collapse every
    // malformed delivery onto one entry and take the real ones with it.
    if (string.IsNullOrWhiteSpace(deliveryId))
    {
        return Results.Unauthorized();
    }

    if (await AlreadyHandledAsync(deliveryId))
    {
        return Results.Ok();
    }

    await HandleAsync(rawBody, deliveryId);
    return Results.Ok();
});

static bool AnyEntryMatches(string signatureHeader, byte[] expected)
{
    const string prefix = "v1=sha256:";
    var matched = false;

    foreach (var entry in signatureHeader.Split(','))
    {
        var trimmed = entry.Trim();

        // The version prefix is checked before the digest is parsed, so an entry from a future
        // scheme is skipped rather than misread as this one.
        if (!trimmed.StartsWith(prefix, StringComparison.Ordinal))
        {
            continue;
        }

        byte[] received;
        try
        {
            received = Convert.FromHexString(trimmed[prefix.Length..]);
        }
        catch (FormatException)
        {
            continue;
        }

        // Constant time. A byte-by-byte compare leaks, through timing, how much of a guessed
        // signature was right, which is enough to forge one. The result is accumulated rather
        // than returned early for the same reason: stopping at the first match would leak
        // which entry your secret sits at.
        matched |= CryptographicOperations.FixedTimeEquals(received, expected);
    }

    return matched;
}

static bool IsFresh(string? timestampHeader, DateTimeOffset now, TimeSpan tolerance)
{
    if (!long.TryParse(timestampHeader, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seconds))
    {
        return false;
    }

    // Parsing as a long is not enough: FromUnixTimeSeconds throws outside the representable
    // range, and a twenty-digit value parses fine.
    if (seconds < DateTimeOffset.MinValue.ToUnixTimeSeconds()
        || seconds > DateTimeOffset.MaxValue.ToUnixTimeSeconds())
    {
        return false;
    }

    var skew = now - DateTimeOffset.FromUnixTimeSeconds(seconds);
    return (skew < TimeSpan.Zero ? -skew : skew) <= tolerance;
}

X-WinkPG-Timestamp carries UNIX epoch seconds. Five minutes either side absorbs ordinary clock skew between your host and the platform's while leaving a captured delivery useless within the hour.

Every rejection above is a status code, never a thrown exception. Your endpoint reads these headers before it has authenticated anything, so a malformed value that made it throw would let anyone turn a bad header into a 500 and a stream of them into an outage. That's also why a malformed entry inside the signature header is skipped rather than failing the whole delivery.

Splitting the signature header isn't optional detail. When the endpoint's secret is rotated, deliveries carry both the current and the incoming signature for the length of the overlap, and a receiver that reads only one value rejects all of them.

Deduplicate on X-WinkPG-Delivery-Id. Delivery is at least once, so the same event can arrive twice, and recording the id in the same transaction as your side effect is what makes a repeat harmless.

2. Register the endpoint

In the portal, open Notifications, then Destinations, and add a destination of type Webhook. It takes two values that matter:

  • The URL of the endpoint you just wrote.
  • A secret key, which is the value your receiver hashes with. Generate a long random string, store it the way you store any other production secret, and put the same value in both places.

Then add a subscription naming the event types you want. The event catalog under Notifications, then Event Types, lists every type your deployment publishes, each with a sample payload. Build against the catalog rather than against a type name you saw elsewhere.

3. Fire a test delivery

Once the destination exists, one call posts a delivery at your endpoint so you can watch it arrive:

curl -X POST "https://your-gateway-host/api/notifications/destinations/{destinationId}/test" \
  -H "api-key: YOUR_API_KEY"

What lands is the standard envelope:

{
  "id": "e2b1a5c4-9d3f-4a17-8c62-51fb0d7a3e88",
  "type": "HostedPaymentPage.Transaction.Completed",
  "version": 1,
  "createdUtc": "2026-08-10T18:42:11.193Z",
  "tenantId": "9c2f1b40-6d84-4c2e-b0aa-1f7d3e5c2a06",
  "merchantId": "3a7e5d21-8f04-49b6-9c31-77b2ea9d1c48",
  "correlationId": "b81c6f92-0e35-4d7a-a2f9-6c4d18ba7305",
  "data": { }
}

correlationId matches the id WinkPG logs for the operation that raised the event, which is what makes tracing a delivery back through both systems straightforward. It's an echo of the X-Correlation-Id header your API request sent, so it's a value you choose: send an order number or an internal request id, never anything personal or sensitive. WinkPG generates a GUID when you send no header. See the Webhook Integration guide for the normalization rules.

4. Acknowledge within five seconds

Acknowledge with any 2xx and aim to do it within five seconds. A 408, a 429, a 5xx, or no response at all is treated as transient and retried with backoff; any other 4xx is permanent and isn't retried.

If your handling is expensive, verify the signature, put the raw body on a queue, return 200, and process it afterward. A slow acknowledgement turns into a retry, and a retry turns into a duplicate you then have to deduplicate anyway.

Next steps

Reconnecting to the server

Could not reconnect

This session has ended

Attempt 1

Your work on this page is still here. Retrying keeps it; reloading starts the page again.

The server no longer holds this page's state, so it has to be loaded again.