View as Markdown

llms.txt

No such blueprint

This instance publishes no blueprint at that address. The catalog lists every one it does publish.

Back to the blueprints

Blueprints Full integrations

Receive and verify webhooks

Stand up a receiver, register it as a signed destination, subscribe to transaction events, then trigger a sandbox sale and prove the delivery that arrives came from this platform.

Signed in, you can run this blueprint against your own sandbox merchant one call at a time, with the values from each call threaded into the next. Sign in to run it.

7 steps, 5 API callsWebhooksNotificationsTransactions

Samples use {{API_KEY}} for your API key and {{BASE_URL}} for this instance's API address. Anything else in double braces is a value an earlier step gave you.

Stand up your receiver

1. Expose an endpoint and verify the signature on it

On your side

Expose an HTTPS endpoint and verify every delivery before you read it. Each delivery is an HTTP POST carrying a JSON envelope, an X-WinkPG-Signature header, and an X-WinkPG-Timestamp header. Verify both headers before you read a single field of the body. A webhook address is a public one, so an unverified receiver acts on anything anyone posts to it. The sample below is this platform's own verification class, published verbatim rather than restated, and it depends on nothing outside the base class library. Four details in it are worth reading rather than skimming. Sign over the raw bytes exactly as received, because re-serializing the body changes them and every signature then fails. Compare in constant time, because a byte-by-byte compare leaks how much of a guessed signature was right. Split the signature header on commas and accept a match on any entry, because it carries two values while the endpoint's secret rotates, and a receiver that reads one value rejects every delivery for the length of the window. Reject a delivery whose timestamp falls outside the replay window, since the timestamp is part of the signed input. Answer 2xx first and do your work afterward. A receiver that finishes the order before it answers is a receiver that gets retried while it's still working. Then pick a signing secret of at least 32 characters, generated at random rather than typed, and keep it wherever you keep your other secrets. You choose it here and hand it to the platform in the next step. Keep it to letters and digits, because it travels inside a JSON string, so a quote or a backslash in it has to be escaped and is a needless way to lose an afternoon.

Reference for this operation

Values this step gives you

  • {{receiverUrl}} The HTTPS address your receiver answers on, plain with no quotes or backslashes in it. It has to be reachable from the internet: the test delivery below is a real request to it.
  • {{signingSecret}} The shared secret you just chose: at least 32 characters, letters and digits only. The platform signs every delivery with it and your receiver verifies against the same value.
.NET
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);
    }
}

Point the platform at it

2. Register your endpoint as a signed destination

API call

POST /api/notifications/destinations

Register the address and the signing secret as a destination. A destination is one place deliveries can go, and its configuration is transport specific, which is why it arrives as a JSON string in configJson rather than as typed fields. The address has to be HTTPS, and the secret has to meet the minimum this instance configures, which is 32 characters unless an administrator has raised it. Get the secret length right here rather than at the next step. Saving the destination accepts a short secret, and the failure comes later when something signs a delivery with it. The test call below is where you find out. Nothing is delivered yet, because a destination on its own has no events attached to it.

Reference for this operation

Values this step gives you

  • {{destinationId}} The id of the created destination, from the response body's id property.
  • {{merchantId}} The merchant the destination belongs to, from the response body's merchantId property. The subscription below is scoped to it.
cURL
curl -X POST "{{BASE_URL}}/api/notifications/destinations" \
  -H "api-key: {{API_KEY}}" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Order service webhook",
    "type": "Webhook",
    "isEnabled": true,
    "configJson": "{\"url\":\"{{receiverUrl}}\",\"secretKey\":\"{{signingSecret}}\"}"
  }'
.NET
using var http = new HttpClient { BaseAddress = new Uri("{{BASE_URL}}") };
http.DefaultRequestHeaders.Add("api-key", "{{API_KEY}}");

// configJson is a string, so the transport configuration is serialized into it
// rather than nested. Build it rather than hand-escaping a JSON literal.
var config = JsonSerializer.Serialize(new
{
    url = receiverUrl,
    secretKey = signingSecret
});

var response = await http.PostAsJsonAsync("/api/notifications/destinations", new
{
    name = "Order service webhook",
    type = "Webhook",
    isEnabled = true,
    configJson = config
});

response.EnsureSuccessStatusCode();

var destination = await response.Content.ReadFromJsonAsync<JsonElement>();
var destinationId = destination.GetProperty("id").GetString();
var merchantId = destination.GetProperty("merchantId").GetString();

What this step answers with

Abridged to the properties this step depends on. A real response carries more.

HTTP 200
{
  "id": "8a0b2c4d-6e7f-4a9b-8c0d-1e2f3a4b5c6d",
  "merchantId": "3a7b1c9d-2e4f-4a6b-8c8d-9e0f1a2b3c4d",
  "name": "Order service webhook",
  "type": "Webhook",
  "scope": "Merchant",
  "status": "Default",
  "isEnabled": true,
  "creationTime": "2026-02-04T18:22:41.517Z"
}

3. Send a test delivery to it

API call

POST /api/notifications/destinations/{{destinationId}}/test

Post a sample delivery to the address you just registered, signed with the secret you just set. Do it now rather than after subscribing, because it's the only step here that isolates the transport. A failure at this point is your endpoint, your certificate, or your firewall, and nothing to do with events. The response carries success along with a message and details when it didn't work. Check your receiver too, since a 200 that your verification rejected is a different problem from one that never arrived, and only your own logs can tell the two apart.

Reference for this operation

Values this step gives you

    cURL
    curl -X POST \
      "{{BASE_URL}}/api/notifications/destinations/{{destinationId}}/test" \
      -H "api-key: {{API_KEY}}"
    .NET
    var test = await http.PostAsync(
        $"/api/notifications/destinations/{destinationId}/test", content: null);
    
    test.EnsureSuccessStatusCode();
    
    var result = await test.Content.ReadFromJsonAsync<JsonElement>();
    var reachable = result.GetProperty("success").GetBoolean();

    Subscribe to the events you want

    4. Wrap the destination in a channel

    API call

    POST /api/notifications/channels

    Wrap the destination in a channel, because a subscription points at a channel rather than at a destination. A channel is a destination plus the formatting the message takes for it. That indirection is the piece most integrations trip over first, and it pays for itself the moment one endpoint serves two subscriptions that want different payloads. Leave the templates unset and the delivery carries the standard envelope, which is what a webhook receiver wants. The templates serve the human-readable transports.

    Reference for this operation

    Values this step gives you

    • {{channelId}} The id of the created channel, from the response body's id property. The subscription references this, not the destination.
    cURL
    curl -X POST "{{BASE_URL}}/api/notifications/channels" \
      -H "api-key: {{API_KEY}}" \
      -H "Content-Type: application/json" \
      -d '{
        "name": "Order service transaction events",
        "destinationId": "{{destinationId}}",
        "isEnabled": true
      }'
    .NET
    var channel = await http.PostAsJsonAsync("/api/notifications/channels", new
    {
        name = "Order service transaction events",
        destinationId,
        isEnabled = true
    });
    
    channel.EnsureSuccessStatusCode();
    
    var channelId = (await channel.Content.ReadFromJsonAsync<JsonElement>())
        .GetProperty("id").GetString();

    What this step answers with

    Abridged to the properties this step depends on. A real response carries more.

    HTTP 200
    {
      "id": "0b2c4d6e-8f9a-4b1c-9d2e-3f4a5b6c7d8e",
      "merchantId": "{{merchantId}}",
      "name": "Order service transaction events",
      "destinationId": "{{destinationId}}",
      "scope": "Merchant",
      "isEnabled": true
    }

    5. Subscribe to the transaction events

    API call

    POST /api/notifications/subscriptions

    The subscription is what finally turns deliveries on: it names the event types you want and the channels they go to. Name the events you will act on rather than everything, because every delivery is a request your receiver has to answer and a retry queue it has to survive. Scope decides how widely it matches, and Merchant is both the narrowest and the only one an API key is entitled to. The events page lists every type with its payload, and the filter expression on this object narrows further when a type alone is too broad.

    Reference for this operation

    Values this step gives you

    • {{subscriptionId}} The id of the created subscription, from the response body's id property. Disable or delete it by this id when you want deliveries to stop.
    cURL
    curl -X POST "{{BASE_URL}}/api/notifications/subscriptions" \
      -H "api-key: {{API_KEY}}" \
      -H "Content-Type: application/json" \
      -d '{
        "name": "Order service transaction results",
        "scope": "Merchant",
        "merchantId": "{{merchantId}}",
        "channelIds": ["{{channelId}}"],
        "eventTypes": ["Transaction.Authorized", "Transaction.Captured", "Transaction.Declined"],
        "isEnabled": true
      }'
    .NET
    var subscription = await http.PostAsJsonAsync("/api/notifications/subscriptions", new
    {
        name = "Order service transaction results",
        scope = "Merchant",
        merchantId,
        channelIds = new[] { channelId },
        eventTypes = new[] { "Transaction.Authorized", "Transaction.Captured", "Transaction.Declined" },
        isEnabled = true
    });
    
    subscription.EnsureSuccessStatusCode();
    
    var subscriptionId = (await subscription.Content.ReadFromJsonAsync<JsonElement>())
        .GetProperty("id").GetString();

    What this step answers with

    Abridged to the properties this step depends on. A real response carries more.

    HTTP 200
    {
      "id": "2c4d6e8f-0a1b-4c3d-8e4f-5a6b7c8d9e0f",
      "merchantId": "{{merchantId}}",
      "name": "Order service transaction results",
      "scope": "Merchant",
      "channelIds": ["{{channelId}}"],
      "eventTypes": ["Transaction.Authorized", "Transaction.Captured", "Transaction.Declined"],
      "isEnabled": true
    }

    Prove it end to end

    6. Trigger a sandbox sale

    API call

    POST /api/transactions

    Nothing so far has produced an event, so make one. This is the same sale the first blueprint takes, at the sandbox's guaranteed approving amount, and it produces the authorization and capture events you subscribed to. Keep the transaction id: matching it against what lands on your receiver is what turns the next step from a hunch into a check.

    Reference for this operation

    Values this step gives you

    • {{transactionId}} The id of the created sale, from the response body's id property. The delivery carries the same id in its data.TransactionId field.
    cURL
    curl -X POST "{{BASE_URL}}/api/transactions" \
      -H "api-key: {{API_KEY}}" \
      -H "Content-Type: application/json" \
      -d '{
        "transactionType": "Sale",
        "cardData": {
          "cardNumber": "4111111111111111",
          "nameOnCard": "Jane Doe",
          "expirationMonth": 12,
          "expirationYear": 2030,
          "cvv": 123
        },
        "invoiceData": {
          "amounts": { "base": 10.00, "total": 10.00 }
        }
      }'
    .NET
    var sale = await http.PostAsJsonAsync("/api/transactions", new
    {
        transactionType = "Sale",
        cardData = new
        {
            cardNumber = "4111111111111111",
            nameOnCard = "Jane Doe",
            expirationMonth = 12,
            expirationYear = 2030,
            cvv = 123
        },
        invoiceData = new
        {
            amounts = new { @base = 10.00m, total = 10.00m }
        }
    });
    
    sale.EnsureSuccessStatusCode();
    
    var transactionId = (await sale.Content.ReadFromJsonAsync<JsonElement>())
        .GetProperty("id").GetString();

    What this step answers with

    Abridged to the properties this step depends on. A real response carries more.

    HTTP 200
    {
      "id": "9f1c2d3e-4b5a-4c7d-8e9f-0a1b2c3d4e5f",
      "merchantId": "3a7b1c9d-2e4f-4a6b-8c8d-9e0f1a2b3c4d",
      "transactionType": "Sale",
      "resultCode": "Ok",
      "authorizedAmount": 10.00,
      "responseData": {
        "resultCode": "Ok",
        "resultMessage": "Approved"
      }
    }

    7. Confirm the delivery arrived and verified

    On your side

    Look at your receiver's log. A delivery for the sale should be there within seconds, its type should be Transaction.Authorized, and its data.TransactionId should be the id the step above returned. Confirm your verification passed rather than that the request arrived, because an endpoint that accepts anything looks exactly like a working one from this side. Two things are worth deciding now rather than in production. Deliveries retry, so the same event can arrive twice and your handler has to be safe to run twice, keyed on the envelope id. Order isn't guaranteed, so treat the payload as a statement about a transaction at a moment rather than as the next entry in a sequence. Repeated failures suppress a destination, which the guide explains along with the retry schedule and how to clear it.

    Reference for this operation

    Values this step gives you

      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.