View as Markdown

llms.txt

Receiving webhooks

WinkPG posts platform events to an HTTPS endpoint you control. This page is the delivery contract: what arrives, how to prove WinkPG sent it, how to avoid acting on the same event twice, and what happens when your endpoint doesn't answer.

The envelope

Every delivery is an HTTPS POST with Content-Type: application/json. The body is the same envelope for every event; only data varies.

Delivery body
{
  "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
  "type": "Transaction.Authorized",
  "version": 1,
  "createdUtc": "2026-01-01T00:00:00.0000000Z",
  "tenantId": "00000000-0000-0000-0000-0000000000a1",
  "resellerId": "00000000-0000-0000-0000-0000000000b2",
  "merchantId": "00000000-0000-0000-0000-0000000000c3",
  "correlationId": "00000000-0000-0000-0000-0000000000d4",
  "data": {}
}
Field Type Description
id string Identifies this event occurrence. Stable across retries and shared by every endpoint the event reaches. For many events it's derived from the entity and the transition rather than random, which is what makes deduplicating on it reliable.
type string The event type, matching an entry on the event reference.
version integer Envelope schema version, currently 1.
createdUtc string When the event occurred, ISO-8601 in UTC.
tenantId string Scope identifier for the instance the event belongs to.
resellerId string Scope identifier for the reseller, when the event is reseller-bound.
merchantId string Scope identifier for the merchant, when the event is merchant-bound.
correlationId string Correlation identifier for the originating operation. Quote it in a support conversation and it can be traced end to end.
data object The event-specific body. Its shape depends on the event type; see the event reference.

version is pinned at 1. New fields are additive within version 1, so parse permissively and ignore what you don't recognize. A change that would break a receiver ships as version 2.

Request headers

Header Value
Content-Type Always application/json.
X-WinkPG-Event-Id The envelope's id, surfaced as a header so it's readable without parsing the body. It identifies the business event, so several deliveries can legitimately share it.
X-WinkPG-Delivery-Id Idempotency key for this delivery: identical on every attempt and every redelivery of the same delivery, and distinct per endpoint the event reaches.
X-WinkPG-Timestamp UNIX epoch seconds, and the first half of the signature input.
X-WinkPG-Signature One or more comma-separated versioned signatures, each in the form v1=sha256: followed by a lowercase hex digest. Two entries appear while the endpoint's secret is being rotated.

The signed input is the X-WinkPG-Timestamp value joined to the body, not the headers themselves. No header is covered by the signature as a header, and none ever has been, so a header added later can't invalidate verification you've already implemented.

Verify the signature

Every request from a destination with a secret key is signed with HMAC-SHA256. Verify it before you do anything else with the body.

  1. Split X-WinkPG-Signature on commas and trim each entry. The header carries one entry per secret the endpoint is currently signed with: one in normal operation, and two while its secret is being rotated. A receiver that assumes a single value rejects every delivery for the length of a rotation.
  2. Check that each entry starts with v1=sha256:, and skip any that doesn't rather than rejecting the delivery. The prefix is what lets the scheme change later without misreading a future signature as this one.
  3. Build the signature input by joining the X-WinkPG-Timestamp header, a literal '.', and the raw request body. The body is the exact bytes you received. Nothing is canonicalized and no whitespace is normalized, so hash the bytes as they arrived rather than a re-serialization of the parsed JSON. One timestamp covers every entry, so this input is the same for each of them.
  4. Compute HMAC-SHA256 over that input with the destination's secret key and compare it against each entry's hex digest, using a constant-time compare. Accept the delivery if any entry matches, and accumulate the result rather than returning at the first match, so the work doesn't depend on where your secret sits in the header.
  5. Check X-WinkPG-Timestamp against your own clock and reject anything outside a five-minute window either side. The signature binds the timestamp to the body, so it can't be altered in flight. This window is what stops a captured request being replayed at you hours later.

The sample below is the implementation this platform tests against its own signer, so a receiver that follows it accepts what WinkPG sends and rejects what it doesn't. The namespace belongs to this platform: change it to yours and the file compiles against the base class library alone.

C#
using System;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;

namespace WinkPG.DeveloperPortal.Webhooks;

/// <summary>
/// Verifies the <c>X-WinkPG-Signature</c> header on an inbound webhook delivery.
/// Copy this into your receiver: it depends on nothing but the base class library.
/// </summary>
public static class WebhookSignatureVerification
{
    /// <summary>Prefix of the version 1 signature scheme.</summary>
    public const string SignaturePrefix = "v1=sha256:";

    /// <summary>
    /// Separator between signature entries. The header carries more than one entry while the
    /// endpoint's signing secret is being rotated.
    /// </summary>
    public const char SignatureSeparator = ',';

    /// <summary>Recommended clock-skew tolerance for the replay window.</summary>
    public static readonly TimeSpan DefaultTolerance = TimeSpan.FromMinutes(5);

    /// <summary>Smallest UNIX-epoch second <see cref="DateTimeOffset"/> can represent.</summary>
    private static readonly long MinUnixSeconds = DateTimeOffset.MinValue.ToUnixTimeSeconds();

    /// <summary>Largest UNIX-epoch second <see cref="DateTimeOffset"/> can represent.</summary>
    private static readonly long MaxUnixSeconds = DateTimeOffset.MaxValue.ToUnixTimeSeconds();

    /// <summary>
    /// Returns true when <paramref name="signatureHeader"/> is a valid signature over
    /// <paramref name="rawBody"/> for the destination's <paramref name="secretKey"/>, and the
    /// delivery is inside the replay window.
    /// </summary>
    /// <remarks>
    /// The header can carry more than one signature, separated by commas, while the endpoint's secret
    /// is being rotated: one entry per secret the platform is currently signing with, the outgoing
    /// one first. Verification succeeds when <em>any</em> entry matches your secret, which is what
    /// lets you keep verifying with the secret you hold while you switch to the new one. A header
    /// with a single entry is the ordinary case and takes the same path.
    /// </remarks>
    /// <param name="signatureHeader">The <c>X-WinkPG-Signature</c> header value.</param>
    /// <param name="timestampHeader">The <c>X-WinkPG-Timestamp</c> header value, UNIX epoch seconds.</param>
    /// <param name="rawBody">The request body exactly as received. Never re-serialize it first.</param>
    /// <param name="secretKey">The destination's shared secret.</param>
    /// <param name="now">The current time. Pass your clock so this stays testable.</param>
    /// <param name="tolerance">Replay window; defaults to five minutes either side.</param>
    public static bool IsValid(
        string? signatureHeader,
        string? timestampHeader,
        byte[] rawBody,
        string secretKey,
        DateTimeOffset now,
        TimeSpan? tolerance = null)
    {
        if (rawBody is null || string.IsNullOrEmpty(secretKey) || timestampHeader is null)
        {
            return false;
        }

        if (!IsInsideReplayWindow(timestampHeader, now, tolerance))
        {
            return false;
        }

        if (signatureHeader is null)
        {
            return false;
        }

        var expected = ComputeSignature(secretKey, timestampHeader, rawBody);

        // Every entry is checked, and the result is accumulated rather than returned early, so the
        // work done does not depend on which entry matched or on how many were present. Returning as
        // soon as one verifies would leak the position of your secret in the header through timing,
        // which during a rotation is the difference between "this receiver still holds the outgoing
        // secret" and "it has cut over".
        var matched = false;

        foreach (var entry in signatureHeader.Split(SignatureSeparator))
        {
            matched |= EntryMatches(entry.Trim(), expected);
        }

        return matched;
    }

    /// <summary>
    /// Whether one <c>v1=sha256:&lt;hex&gt;</c> entry equals <paramref name="expected"/>.
    /// </summary>
    /// <remarks>
    /// 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. A malformed entry is a non-match, never an exception:
    /// this runs before anything has been authenticated, so a header that could make it throw would
    /// let anyone turn a bad request into a 500.
    /// </remarks>
    private static bool EntryMatches(string entry, byte[] expected)
    {
        if (!entry.StartsWith(SignaturePrefix, StringComparison.Ordinal))
        {
            return false;
        }

        byte[] received;
        try
        {
            received = Convert.FromHexString(entry[SignaturePrefix.Length..]);
        }
        catch (FormatException)
        {
            return false;
        }

        // Constant-time compare. A byte-by-byte compare leaks, through timing, how much of a guessed
        // signature was correct, which is enough to forge one.
        return CryptographicOperations.FixedTimeEquals(received, expected);
    }

    /// <summary>
    /// Computes the expected signature: HMAC-SHA256 over the bytes of
    /// <c>"{timestamp}.{body}"</c>, keyed with the destination secret.
    /// </summary>
    public static byte[] ComputeSignature(string secretKey, string timestamp, byte[] rawBody)
    {
        var prefix = Encoding.UTF8.GetBytes(timestamp + ".");
        var input = new byte[prefix.Length + rawBody.Length];
        prefix.CopyTo(input, 0);
        rawBody.CopyTo(input, prefix.Length);

        using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secretKey));
        return hmac.ComputeHash(input);
    }

    /// <summary>
    /// Whether the delivery's timestamp is inside the replay window. A missing, unparsable or
    /// out-of-range timestamp fails: the timestamp is part of the signed input, so a delivery without
    /// a usable one cannot have been signed by us.
    /// </summary>
    /// <remarks>
    /// Every rejection is a <c>false</c>, never an exception. A receiver reads this header before it
    /// has authenticated anything, so a value that made it throw would let anyone turn a malformed
    /// header into a 500, and a stream of them into an outage.
    /// </remarks>
    public static bool IsInsideReplayWindow(string? timestampHeader, DateTimeOffset now, TimeSpan? tolerance = null)
    {
        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 "1" followed by twenty digits parses fine.
        if (seconds < MinUnixSeconds || seconds > MaxUnixSeconds)
        {
            return false;
        }

        var sent = DateTimeOffset.FromUnixTimeSeconds(seconds);
        var skew = now - sent;
        if (skew < TimeSpan.Zero)
        {
            skew = -skew;
        }

        return skew <= (tolerance ?? DefaultTolerance);
    }
}

Never log the secret key, and rotate it on a schedule. A rotation isn't disruptive, but it does have an order: make sure your receiver reads every comma-separated entry in X-WinkPG-Signature before the rotation is started. For the length of the overlap, deliveries carry both the outgoing and the incoming signature, so a receiver that reads only one value rejects all of them. Repeated rejections are treated as authentication failures and stop delivery to the endpoint until an administrator clears the suppression.

Handle duplicate deliveries

Delivery is at least once. A response from WinkPG that's lost in flight, a broker redelivery, or a retry after a timeout can each put the same request in front of you twice, so a receiver that isn't idempotent eventually double-charges something.

  1. Read X-WinkPG-Delivery-Id.
  2. If you have already processed that id, answer 200 and stop.
  3. Otherwise process the request and record the id in the same transaction as the side effect, so a partial failure rolls back both.

Both ids are stable across every attempt and every redelivery, but they answer different questions. X-WinkPG-Delivery-Id is the default: one value per endpoint per event, so it guards a side effect that should run once per request you receive. X-WinkPG-Event-Id identifies the business event itself, and one event can reach several endpoints, so key on it only when the side effect must run at most once across all of them. Where they differ, the delivery id is the safer choice: it never suppresses a delivery you were meant to act on.

Answer a delivery

Answer 2xx once the work is committed or durably queued, and aim to do it within five seconds. The per-attempt timeout is 30 seconds by default, but a slow receiver raises end-to-end latency and makes a duplicate delivery more likely. When the work is expensive, verify the signature, enqueue the raw body and headers, answer 200, and process asynchronously.

You answer The delivery Your endpoint
Any 2xx Succeeded. Nothing further is sent for it. Any failure streak is cleared.
408, 429 or any 5xx Retried with exponential backoff, up to five attempts by default. Suppressed after ten consecutive failures inside a one-hour window.
No response, or a connection, DNS or TLS failure Treated as a transient failure and retried the same way. Counts toward the same streak as a 5xx.
401 or 403 Retried. These are usually temporary in practice (a deploy, a rule change, a secret rotation), so they keep their retry budget. Suppressed after three consecutive failures that also span at least fifteen minutes, which is what stops one bad minute taking the endpoint offline. Expires after an hour.
Any other 4xx Treated as permanent and dead-lettered on the first attempt, with no retry. Suppressed immediately, expiring after 24 hours.

The numbers on this page are the shipped defaults. An operator can tune the retry budget, the backoff and the suppression thresholds per instance, so treat them as the shape of the behavior rather than as guaranteed constants.

Retries and dead-lettering

A retryable failure is retried with exponential backoff and jitter: 60 seconds before the first retry, doubling each time, with up to 25 percent of the interval added at random and the whole thing capped at one hour. Five attempts is the default budget. A delivery that exhausts the budget is dead-lettered, which is terminal: nothing further is sent for it automatically, and it stays visible so an operator can retry it by hand once the endpoint is healthy again.

A permanent failure skips the ladder entirely and dead-letters on the first attempt. That's the point of answering 400 or 422 to something you'll never accept: it stops WinkPG retrying a request that can't succeed. Never use those codes for a problem on your side that will clear.

Suppression

An endpoint that keeps failing is suppressed, and deliveries to it are then skipped without an outbound call at all. Suppression exists so one broken receiver can't absorb the platform's delivery capacity. It's worth understanding, because a suppressed endpoint goes quiet in a way that looks exactly like no events happening.

  • Failures accrue per endpoint, not per subscription. Two destinations pointed at the same address share one streak, and a retry of one delivery counts alongside a fresh delivery to the same place.
  • A success clears the streak, so an endpoint that fails intermittently and recovers is never suppressed.
  • A suppression expires on its own (one hour after an authentication rejection, 24 hours otherwise) and the next event is allowed through as a single probe. If it succeeds the endpoint heals; if it fails the suppression re-arms.
  • Deliveries skipped while suppressed are recorded as skipped rather than failed, and the delivery log labels each one as skipped because delivery was paused, so the quiet is visible. They can be retried by hand once the suppression is cleared.

Whoever owns the destination can see an active suppression and clear it ahead of its expiry. If your endpoint went quiet after an outage on your side, check that first.

Recover missed events

If your receiver was unavailable for a while, the deliveries that failed while it was down are still on record. Two ways get them back, and they answer different questions: pull tells you what you missed, replay sends it to you again.

Ask for the window your receiver was down for and page through what WinkPG sent in it. The list read gives you one page of deliveries with their status and a continuation token; follow the token until it comes back empty. Fetch a single delivery to get the body WinkPG posted, byte for byte, so you can process it exactly as you would have processed the original. Reading a delivery isn't a replay: nothing is resent and nothing about the delivery changes. A card lifecycle event that WinkPG lost before dispatch has no delivery to find here yet; the daily reconciliation re-emits it in almost all cases within 48 hours of the transition, after which it's listed and readable exactly like the rest. That recovery is bounded rather than guaranteed, so keep reading transaction state through the API where your reconciliation depends on seeing every transition.

Shell
# The deliveries sent between these two instants.
curl -s -H "api-key: $WINKPG_API_KEY" \
  "$WINKPG_API/api/notifications/events?FromUtc=2026-08-12T00:00:00Z\
&ToUtc=2026-08-12T06:00:00Z&Status=Failed&MaxResultCount=100"

# The body of one delivery.
curl -s -H "api-key: $WINKPG_API_KEY" \
  "$WINKPG_API/api/notifications/events/$DELIVERY_ID"
  • The deliveries you get back are your own merchant's webhook deliveries and nothing else. A credential that resolves no single merchant is refused rather than answered with everybody's.
  • A window covers at most 31 days and a page carries at most 100 deliveries. A wider window is refused rather than narrowed, because on a recovery call a shortened answer looks exactly like a period in which nothing happened. Read a longer period in steps.
  • Narrow by status to find only the ones that didn't land: Failed for a delivery still on the retry ladder, DeadLettered for one that exhausted it, Skipped for one WinkPG held back while your endpoint was suppressed.
  • The event id on each delivery is the same one the original envelope carried, so a receiver that already deduplicates on it can process a recovered event through the same path as a live one.

If you'd rather WinkPG sent them again than fetch them yourself, ask it to. There's a replay for one delivery and a replay for every replayable delivery in a window, and both are available over the API and on the webhook deliveries page in your portal. Each one sends the same event to the same endpoint with the same signature headers the original carried, and records the result as a new attempt. The API call runs the replay while you wait and answers with what happened, so a recovery script can finish the loop without anyone opening a page.

Shell
# Send one delivery again, and wait for the outcome.
curl -s -X POST -H "api-key: $WINKPG_API_KEY" \
  "$WINKPG_API/api/notifications/events/$DELIVERY_ID/replay"

# Send every replayable delivery in a window again.
curl -s -X POST -H "api-key: $WINKPG_API_KEY" \
  -H "Content-Type: application/json" \
  "$WINKPG_API/api/notifications/events/replay" \
  -d '{"fromUtc":"2026-08-12T00:00:00Z","toUtc":"2026-08-12T06:00:00Z"}'
  • Only a delivery that failed, gave up or was skipped can be replayed. One that WinkPG accepted, or one that's being sent right now, can't. Neither can an event that never became a delivery, because there's nothing on record to send.
  • Replays to one endpoint are limited to 10 replays every 5 minutes, and the single replay and the bulk replay share that budget. That's deliberate: it stops a bulk replay stampeding a receiver you've only just repaired. The window is fixed and opens with the first replay in it, so a spent budget comes back whole within 5 minutes, not one replay at a time. Over the API, a request over the budget answers 429 and changes nothing.
  • A bulk replay takes on a bounded batch and reports what it sent, what it held back and why. Over the API, run the same request again while reachedCap is true or notAttempted is above zero. There's no idempotency key to send: a delivery that succeeded on the previous run is no longer replayable, so a repeat run only touches what's still outstanding.
  • Fix your receiver first. A replay against an endpoint that's still failing spends your budget and adds a second failed attempt to the record.

Develop against a local endpoint

Everything above assumes a publicly reachable HTTPS endpoint, which is a lot to stand up before you've written a single line of your handler. The integrator CLI removes that step: it opens a short-lived listen session, receives your merchant's events, and posts each one to an address on your own machine. Nothing about the body changes, so the handler you write here is the handler you ship.

Put your API key in the WINKPG_API_KEY environment variable and the environment's base address in WINKPG_BASE_URL, then run the command below. The key isn't accepted as a command-line option, because arguments are visible in your shell history and to anyone who can list processes on the machine.

Shell
winkpg-integrator listen --forward-to http://localhost:4242/webhook
  • The body is forwarded byte for byte. It's the same JSON a configured destination would have received for the same event, so a parser you validate here is validated for production.
  • The session mints its own signing secret and prints it once, at startup. The CLI signs each forwarded event with it using the scheme above, so your verification code works unchanged. The secret can't be retrieved afterward: if you lose it, start a new session.
  • X-WinkPG-Event-Id and X-WinkPG-Delivery-Id are passed through, and the timestamp and signature are computed at the moment the event is forwarded, so a session you resume after a break still verifies inside your tolerance window.
  • A session receives events whether or not you've configured a destination or a subscription. That's the point: you can develop your receiver before deciding where production traffic should go.
  • Stopping and restarting resumes from where it left off. Events that expired from the buffer while you were away are reported as a gap rather than skipped without notice.

This is a development tool, not a delivery channel. A session lasts minutes to hours and buffers a limited window of events. Production integrations use a configured destination and the delivery guarantees described above.

Trigger a sample event

A listener is only half the loop: with one running you still have to wait for a real event, or stage a payment to provoke one, before your handler runs at all. The same CLI can send a sample of any event type your key can see straight into your running listener, so you can write a handler and exercise it in the same sitting.

Leave the listener running in one terminal and trigger from another. The listener records its session so the trigger finds it. If you have more than one open, pass --session with the id the listener printed when it started.

Shell
winkpg-integrator trigger --list
winkpg-integrator trigger Transaction.Completed
  • The body is the sample the event catalog publishes for that type, composed by the platform through the same path a real delivery takes. The CLI builds no part of it, so what your handler receives is the shape a real occurrence produces.
  • It arrives on your listener like any other event: forwarded byte for byte, signed with the session's secret, and carrying its own event and delivery ids. It also carries X-WinkPG-Sample: true, the same marker a sample sent to a configured endpoint carries, so your handler can tell it apart from a real event. A real event forwarded by the listener never carries it.
  • Triggering creates no delivery record and changes no destination's health. A sample is a diagnostic, and it doesn't appear in your delivery history.
  • trigger --list shows the event types your key can see, which is the same set a listen session opened with that key receives. That listing is scoped to your key and isn't itself the sandbox check: a live key lists the catalog and can trigger none of it. A type you can't see is refused rather than ignored.

Sample events are sandbox only. Triggering with a live key is refused by the platform, not by the CLI, and the refusal names the reason: a sample is a synthetic event that never happened, so it's never delivered alongside a merchant's real traffic. Use a test key (sk_test_) against the same merchant's sandbox.

Verify your handler before you ship

Watching your handler run isn't the same as knowing it's ready. The same CLI can send your handler one sample of every documented event type, check that each answer is a 2xx that arrives within the delivery deadline, confirm that the handler refuses a forged signature, and print a pass or fail report you can run in CI.

Start your handler, then run the command below. You don't need a listener running: the command opens its own listen session and ends it when it finishes. Put the secret your handler verifies with in WINKPG_WEBHOOK_SECRET, so each sample is signed with it. Like the API key, the secret isn't accepted as a command-line option, and it never leaves your machine.

Shell
winkpg-integrator verify-webhooks --forward-to http://localhost:4242/webhook --json webhook-report.json
  • The list of event types comes from the event reference. Every type whose payload is documented and that the platform can deliver is checked, so a type documented later is checked with no update to the CLI. Types that are pending, not deliverable, or not available to your key are listed as skipped, with the reason.
  • An event type passes when your handler answers with a 2xx within the deadline, which is 30 seconds unless you pass --deadline-seconds to match a destination you've configured with a shorter timeout. A non-2xx answer, a 2xx that arrives after the deadline, and no answer at all each fail, because the platform records each as a failed delivery and retries it.
  • After the sweep, one more sample is sent with a signature computed over the wrong secret. Your handler passes by answering with a 4xx. Accepting it with a 2xx fails the check, because anyone who can reach your endpoint could then send it events. Pass --skip-tamper-check to leave this check out.
  • The exit code is 0 when every check passed or was skipped, 2 when at least one failed or couldn't be decided, and 1 when the run couldn't happen at all, for example because the key was refused. --json writes the same results as a report a CI job can keep. It holds status codes and timings, never a payload, a secret or a key.
  • If WINKPG_WEBHOOK_SECRET isn't set, each sample is signed with the run's own session secret, which the CLI prints once. A handler that verifies signatures refuses those samples unless it's configured with that secret.

Verification is sandbox only, because it stages sample events. The platform refuses a live key on the first sample, and the run ends without a report. Use a test key (sk_test_).

Transport

  • Production destinations are HTTPS only, and your certificate has to chain to a public certificate authority. Self-signed certificates are rejected.
  • Redirects aren't followed. Point the destination at its final address.
  • A delivery body is capped at 1 MB. An event whose body would exceed the cap fails rather than being truncated.
  • If your receiver filters by source address, allow-list the addresses below rather than inferring them from traffic.

Allow-list the webhook origins

If your receiver sits behind a firewall that filters by source address, these are the addresses WinkPG delivers from. Deliveries leave from more than one place: routine deliveries and retries, a test send from the platform, and a sample send you trigger yourself don't all originate the same way, so allow-list every address here rather than the one you happen to see first.

This instance hasn't published its delivery addresses yet. Ask your account representative for the current set before you build a rule that filters by source address.

Source addresses are infrastructure, not contract: they can change when this platform's hosting does. Check this page before an address-based rule goes live, and prefer verifying the signature on each delivery, which doesn't depend on where it came from.

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.