# Receiving webhooks

WinkPG posts platform events to an HTTPS endpoint you control. This page is the delivery contract: what arrives, how to prove it came from us, how to avoid acting on the same event twice, and what happens when your endpoint does not 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.

```json
{
  "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 is 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 do not recognise. 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 is 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 | The versioned signature, in the form v1=sha256: followed by a lowercase hex digest. |

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 cannot invalidate verification you have already implemented.

## Verifying 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. Check that X-WinkPG-Signature starts with v1=sha256:. Reject it if not: the prefix is what lets the scheme change later without silently misreading a future signature as this one.
2. 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. There is no canonicalization and no whitespace normalization, so hash the bytes as they arrived rather than a re-serialization of the parsed JSON.
3. Compute HMAC-SHA256 over that input with the destination's secret key and compare it against the hex digest from the header, using a constant-time compare.
4. 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 cannot 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 we send and rejects what we do not. The namespace is ours: change it to yours and the file compiles against the base class library alone.

```csharp
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>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>
    /// <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;
        }

        // Check the version prefix before parsing the digest, so a future scheme is rejected rather
        // than misread as this one.
        if (signatureHeader is null || !signatureHeader.StartsWith(SignaturePrefix, StringComparison.Ordinal))
        {
            return false;
        }

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

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

        // 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. Rotation is not disruptive if your receiver validates against the previous and the current secret during the changeover.

## Handling duplicates

Delivery is at least once. A response of ours that is lost in flight, a broker redelivery, or a retry after a timeout can all put the same request in front of you twice, so a receiver that is not idempotent will eventually double-charge 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 match several subscriptions and 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.

## Answering 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. If 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 behaviour 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 it 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 is the point of answering 400 or 422 to something you will never accept: it stops us retrying a request that cannot succeed. Do not 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. This exists so one broken receiver cannot absorb the platform's delivery capacity, and it is worth understanding because a suppressed endpoint is silent in a way that looks exactly like no events happening.

- Failures accrue per endpoint, not per subscription. Two destinations pointed at the same URL 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 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, that is the first thing to check.

## Transport

- Production destinations are HTTPS only, and your certificate has to chain to a public certificate authority. Self-signed certificates are rejected.
- Redirects are not followed. Point the destination at its final URL.
- A delivery body is capped at 1 MB. An event whose body would exceed the cap is not truncated silently; it fails.
- If your receiver filters by source address, ask your account representative for the current egress list rather than inferring it from traffic.

- [Webhook event reference](https://docs.winkpg.io/docs/webhooks/events.md): every event type this platform publishes.

## See also

- [All documentation](https://docs.winkpg.io/llms.txt): the machine-readable index of every public page on this site.
