Take a payment with a hosted payment page
Configure a hosted page, open a session for one payment, send the customer to it, then reconcile the result from the webhook rather than from the redirect.
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 callsHosted Payment PagesPaymentsWebhooks
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.
Configure the page
1. Read your merchant id off a sandbox sale
API call
POST /api/transactions
A hosted page belongs to one merchant, and the create call names it: your key belongs to exactly one, but the page create doesn't fill it in for you. There's no call that answers with that id on its own, so read it off the first merchant-scoped record your key creates: here, a card sale at the amount the sandbox always approves. If you have already run another flow on this site, the merchantId on any of its responses is the same value.
Values this step gives you
{{merchantId}}The merchant the sale belongs to, from the response body's merchantId property. The hosted page is created for this merchant.
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 }
}
}'using var http = new HttpClient { BaseAddress = new Uri("{{BASE_URL}}") };
http.DefaultRequestHeaders.Add("api-key", "{{API_KEY}}");
var response = 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 }
}
});
response.EnsureSuccessStatusCode();
var sale = await response.Content.ReadFromJsonAsync<JsonElement>();
var merchantId = sale.GetProperty("merchantId").GetString();What this step answers with
Abridged to the properties this step depends on. A real response carries more.
{
"id": "9f1c2d3e-4b5a-4c7d-8e9f-0a1b2c3d4e5f",
"merchantId": "3a7b1c9d-2e4f-4a6b-8c8d-9e0f1a2b3c4d",
"transactionType": "Sale",
"resultCode": "Ok",
"authorizedAmount": 10.00
}2. Create the hosted payment page
API call
POST /api/hostedpaymentpages
Create the page once, then open a session against it per payment. The page is the reusable configuration: what it looks like, which methods it accepts, and where it sends the customer afterward. Creating a page per payment is the common first mistake and leaves you with an unmanageable list of them. Send the merchantId you read off the sale: the create refuses a page that names no merchant.
Values this step gives you
{{hostedPageId}}The id of the created page, from the response body's id property.
curl -X POST "{{BASE_URL}}/api/hostedpaymentpages" \
-H "api-key: {{API_KEY}}" \
-H "Content-Type: application/json" \
-d '{
"merchantId": "{{merchantId}}",
"name": "Online checkout",
"isActive": true,
"allowCustomAmount": false,
"successRedirectUrl": "https://example.test/thank-you"
}'var page = await http.PostAsJsonAsync("/api/hostedpaymentpages", new
{
merchantId,
name = "Online checkout",
isActive = true,
allowCustomAmount = false,
successRedirectUrl = "https://example.test/thank-you"
});
page.EnsureSuccessStatusCode();
var created = await page.Content.ReadFromJsonAsync<JsonElement>();
var hostedPageId = created.GetProperty("id").GetString();What this step answers with
Abridged to the properties this step depends on. A real response carries more.
{
"id": "4d6e8f0a-1b2c-4d4e-9f6a-7b8c9d0e1f2a",
"merchantId": "3a7b1c9d-2e4f-4a6b-8c8d-9e0f1a2b3c4d",
"name": "Online checkout",
"isActive": true,
"successRedirectUrl": "https://example.test/thank-you",
"creationTime": "2026-02-04T18:22:41.517Z"
}Send the customer to it
3. Open a session for this payment
API call
POST /api/hostedpaymentpages/sessions
A session is one customer's one visit to the page: it carries the amount, expires on its own, and is what the resulting transaction is attributed to. Open a fresh one per payment and never reuse a session id across customers. Make the create safe to retry by sending an idempotencyKey alongside the body, keyed on your own order. A resend of the same request returns the session already opened, with the same session id, link and expiry, instead of handing the customer a second payment link; reusing the key for a different request is refused rather than answered with a link for the wrong order. The response reports what the key did on idempotencyStatus, and the value to check for is KeyIgnored: it means deduplication isn't enabled for the merchant yet, so the key bought nothing. It's a body field, the same as on the transaction create; no Idempotency-Key header is read. To give the customer a way back to your cart without paying, send a cancelUrl: an absolute HTTPS address of up to 200 characters. The hosted page shows a Back link to it next to the pay button, and it overrides any Back link configured on the page itself.
Values this step gives you
{{sessionId}}The id of the opened session, from the response body's sessionId property.
curl -X POST "{{BASE_URL}}/api/hostedpaymentpages/sessions" \
-H "api-key: {{API_KEY}}" \
-H "Content-Type: application/json" \
-d '{
"hostedPageId": "{{hostedPageId}}",
"label": "Order 1042",
"expirySeconds": 900
}'var session = await http.PostAsJsonAsync("/api/hostedpaymentpages/sessions", new
{
hostedPageId,
label = "Order 1042",
expirySeconds = 900
});
session.EnsureSuccessStatusCode();
var opened = await session.Content.ReadFromJsonAsync<JsonElement>();
var sessionId = opened.GetProperty("sessionId").GetString();What this step answers with
Abridged to the properties this step depends on. A real response carries more.
{
"sessionId": "6f8a0b2c-4d5e-4f7a-8b9c-0d1e2f3a4b5c",
"shortToken": "s7Kd2QhRmT9x",
"label": "Order 1042",
"expiresAt": "2026-02-04T18:22:43.062Z",
"effective": true
}4. Get the session's payment link
API call
GET /api/hostedpaymentpages/sessions/{{sessionId}}/payment-link
Ask for the address the customer opens. Don't assemble it yourself from the session id. The platform issues the link for this session, and an assembled one stops working the moment the address scheme changes.
Values this step gives you
{{paymentLinkUrl}}The address to send the customer to.
curl "{{BASE_URL}}/api/hostedpaymentpages/sessions/{{sessionId}}/payment-link" \
-H "api-key: {{API_KEY}}"What this step answers with
Abridged to the properties this step depends on. A real response carries more.
"https://pay.example.test/pay/s/s7Kd2QhRmT9x"5. Send the customer to the page
In the browser
Redirect the customer's browser to the payment link. The customer enters the card on the hosted page, where it never reaches your server, which is the whole reason to use one. Treat the customer returning to your success address as a hint that the payment finished, not as proof of it. A customer who closes the tab after paying never comes back. If you sent a cancelUrl, or the page has a Back link configured, the customer can follow it back to your cart before paying. That leaves the session untouched: the same payment link still works if they return to pay. The Back link isn't shown on a page embedded in your own site or app (a session with a parentOrigin, the Embedded presentation mode, or the native WebView host channel), where your integration owns navigation.
Values this step gives you
HTTP/1.1 303 See Other
Location: {{paymentLinkUrl}}Reconcile the payment
6. Verify the webhook and read the result from it
On your side
Verify the webhook's signature before you trust anything in the body, then take the transaction id from the delivery. The transaction webhook is the authoritative notification, and it arrives whether the customer came back or not. The sample below is the platform's own verification class, published verbatim rather than restated.
Values this step gives you
{{transactionId}}The transaction id carried on the verified webhook delivery.
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:<hex></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);
}
}
7. Read the transaction
API call
GET /api/transactions/{{transactionId}}
Read the transaction the session produced and reconcile it against your order. This is the same read as in the card-sale blueprint. However you collect the payment, the record it produces is one transaction.
Values this step gives you
curl "{{BASE_URL}}/api/transactions/{{transactionId}}" \
-H "api-key: {{API_KEY}}"