API conventions
Every endpoint in the WinkPG API follows the same handful of rules. This page is the reference for them: how you authenticate, how you make a create safe to retry, how you page a list, what shape instants and amounts take, and where to find the error contract.
Authentication
Send your API key in the api-key request header on every call. There's no separate login step and no token to refresh.
POST /api/transactions
api-key: sk_live_YOUR_KEY
Content-Type: application/json- A key you create carries its environment in the value. Test keys start with sk_test_ and live keys start with sk_live_, so you can tell which environment a configured key belongs to without looking it up. Keys minted before the prefixes existed still work and carry no prefix, so don't validate a key's shape in your own code.
- A key's environment is fixed when you create it and is never re-stamped. Send an sk_test_ key for a merchant that has since gone live and the call returns 401 with the code KEY_ENVIRONMENT_MISMATCH. Create a fresh key for the merchant as it stands now.
- A refused key returns 401 with a code in the body: KEY_INVALID, KEY_REVOKED, KEY_EXPIRED, KEY_SUSPENDED, KEY_ENVIRONMENT_MISMATCH, or TRIAL_EXPIRED. Branch on the code. Treat all but KEY_SUSPENDED as terminal rather than retryable; TRIAL_EXPIRED is about the merchant's plan rather than the credential, so minting another key won't clear it.
- KEY_SUSPENDED is the one refusal that can be undone. The key has been taken out of service temporarily, either by an administrator or because the platform saw it used from a network it has never been used from before, and the same key value starts working again once an administrator reinstates it. Ask them to before you mint a replacement: a new key leaves the suspended one to be reinstated behind you.
- A scoped key that authenticates but doesn't cover the operation is a different answer: 403 with KEY_SCOPE_INSUFFICIENT. Nothing is wrong with the credential, so don't handle it alongside the 401s.
- Log the code, never the key. The code is the diagnostic, and the value you sent is a live credential that belongs in your secret store and nowhere else.
- Handle a code or a status value you don't recognize rather than rejecting it. Codes and enumerated values are added over time, always alongside the existing ones and never in place of them, so a deserializer that throws on an unfamiliar value turns an addition to the platform into an outage in your integration. Treat an unrecognized 401 code as a refusal you shouldn't retry, and an unrecognized status as one you don't act on.
Creating a key, narrowing it with scopes, and the expiration reminders are covered in the getting-started guide. Getting started with the API
Idempotency on create
A create that times out in transit leaves a real question behind: did it go through? An idempotency key answers it. Three operations accept one, each as a field named idempotencyKey on the create request body: the transaction create, the hosted payment page session create, and the standalone token create. Set it to a value you can reproduce on retry. The rules aren't identical across the three, so find your surface below before you build against them.
| Create | Route | Does a key deduplicate? |
|---|---|---|
| Transaction create | POST /api/transactions |
Only once deduplication is enabled for the merchant. Until then the key is accepted and stored, and a repeat send charges again. |
| Hosted payment page session create | POST /api/hostedpaymentpages/sessions |
Only once deduplication is enabled for the merchant, through the same setting the transaction create reads. Until then a repeat send opens a second session. |
| Standalone token create | POST /api/tokens/create-standalone-async |
Always. There is no setting to enable on this surface, so a key you send deduplicates from the first call, for every merchant. |
{
"idempotencyKey": "order-48213",
"transactionType": "Sale",
"invoiceData": {
"amounts": { "base": 49.00 }
}
}On all three it's a field on the request body rather than a request header, so it travels with the payload. The field name is the same on each, and so is the shape of the value.
Read the status the response reports
Every one of the three create responses carries idempotencyStatus, reporting what idempotency did to that request. Read it rather than inferring protection from the 2xx, and read it there: it's a create-response field, and reading the resource back later won't give you a second chance at it. A transaction read back carries the field with no value in it; a session and a token don't carry it at all. The values are spelled the same way on each surface, but they aren't the same set: two of the three can report that your key did nothing, and the third has no such case to report.
Transaction create
POST /api/transactions
Four values. KeyIgnored is the one to check for first: it says deduplication isn't enabled for this merchant yet.
| idempotencyStatus | What it means |
|---|---|
NotRequested |
You sent no key. A repeat send will charge again. |
KeyIgnored |
You sent a key, but deduplication isn't enabled for this merchant, so the key had no deduplication effect. A repeat send will charge again. |
KeyAccepted |
You sent a key and deduplication is enabled. This is the original create, and the key now protects it. |
Replayed |
Your key matched a create that had already completed. Nothing was charged, and the body is that original transaction. |
Hosted payment page session create
POST /api/hostedpaymentpages/sessions
The same four values, read the same way, governed by the same merchant setting.
| idempotencyStatus | What it means |
|---|---|
NotRequested |
You sent no key. A repeat send opens a second session and hands out a second payment link. |
KeyIgnored |
You sent a key, but deduplication isn't enabled for this merchant, so the key had no deduplication effect. A repeat send opens a second session. |
KeyAccepted |
You sent a key and deduplication is enabled. This is the original create, and the key now protects it. |
Replayed |
Your key matched a create that had already completed. No second session was opened, and the body is that original session, with its original short token, link and expiry. |
Standalone token create
POST /api/tokens/create-standalone-async
Three values, not four. There is no KeyIgnored here, because there is no setting that could leave your key doing nothing. If you sent a key, it deduplicated.
| idempotencyStatus | What it means |
|---|---|
NotRequested |
You sent no key. A repeat send mints a second token for the same card. |
KeyAccepted |
You sent a key and this request claimed it. This is the original create, and the key now protects it. Nothing had to be enabled first. |
Replayed |
Your key matched a create that had already produced a token. Nothing was minted, and the body is that original token, with its original identifiers and public reference. |
What the key does where it deduplicates
- A repeat replays instead of doing the work again. Send the same key again and you get the original resource back with its original ids and timestamps: the original transaction, the original session, or the original token. The window is 48 hours from the original create on all three. After that the same key counts as a fresh request.
- A retry that arrives while the original is still running is answered immediately rather than held open on the other request's result. All three return 409, each with its own code, and none of them carries a Retry-After. Retry shortly.
- What a changed body does under a key you've already used is the sharpest difference between the three. On the transaction create the body isn't compared: the first request wins, so a second payload under the same key is discarded rather than rejected. On the session create and the token create it's compared, and a key reused for a genuinely different request is refused rather than answered with the wrong resource. Reuse a key only for a real retry of the same request and the difference never reaches you.
- What counts as a different request differs too, and it follows what each surface is for. The session create compares the request as you sent it, because on that surface the request body is the order. The token create compares the instrument instead: the merchant, the customer, the token's type and category, and the card or account itself. A corrected cardholder name or billing address is the same card, so it replays the original token; a different card, or the same card filed under a different type or category, is refused.
- Keys are scoped to your merchant on the transaction create, so they only ever collide with your own. On the session create and the token create they're scoped to your merchant and the credential that sent them, so the same key sent under a second API key for one merchant is a different key and won't replay. Keep a retry on the credential that made the original call.
- The key is validated the same way on all three: up to 128 characters, made of letters, digits, and the characters '.', '_', ':', and '-'. When that check runs is what differs. The session create and the token create enforce it on every request. The transaction create enforces it only once deduplication is enabled for the merchant, so a key that's too long or carries a stray character is accepted there while the setting is off and starts returning 400 the day it's turned on. Generate keys inside the rules from the start and none of that timing matters.
- Don't start a key with rb:, inv-charge:, or inv-installment:. Those prefixes are reserved for the scheduled charges recurring billing and invoicing generate, and they're rejected on all three surfaces whatever any setting says.
- A key whose original resource no longer exists is refused rather than quietly reused, on the two surfaces that can tell. Opening a second session, or minting a second token, under a key whose first one can't be accounted for is the duplicate the key exists to prevent. Use a new key.
- If the deduplication check itself can't complete, the create is refused with 429 rather than running unprotected, on all three. Nothing was created, so the request is safe to retry.
The codes each surface returns
These are the codes to branch on around a create. They're per surface, and the lists aren't the same length: the transaction create has no reused-key refusal to return, because it doesn't compare a repeat's body against the original.
Transaction create
Two codes, and the absence is the thing to notice: there's no reused-key refusal here. A second payload under a key this merchant has already used is discarded rather than rejected, and you get the original transaction back.
| error.code | HTTP | What it means |
|---|---|---|
Transactions:CreateIdempotencyInProgress |
409 | The original create under this key is still running. Nothing was charged. Retry shortly, or look the transaction up by its key. |
Transactions:CreateIdempotencyStoreUnavailable |
429 | The deduplication check couldn't complete, so the create was refused rather than run unprotected. Nothing was charged, so this is safe to retry. |
Hosted payment page session create
Four codes. All three 409s mean no session was opened and no link was handed out.
| error.code | HTTP | What it means |
|---|---|---|
HostedPaymentPage:HppSession:CreateIdempotencyKeyReused |
409 | This key was already used for a different request, so replaying would hand back a session that isn't what you asked for. Nothing was created. Send a fresh key when the request changes. |
HostedPaymentPage:HppSession:CreateIdempotencyInProgress |
409 | The original create under this key is still running. Retry shortly; the retry replays the original once it completes. |
HostedPaymentPage:HppSession:CreateIdempotencySessionGone |
409 | The key can no longer be resolved to a session, so the original response can't be returned again. Nothing was created, and the first session may already have been paid. Use a new key to open a new session. |
HostedPaymentPage:HppSession:CreateIdempotencyStoreUnavailable |
429 | The deduplication check couldn't complete, so the create was refused rather than risk a second payment link. Retry the request. |
Standalone token create
Four codes, matching the session create's shape. All three 409s mean nothing was minted. Two of them need something changed before you send again, a fresh key; the in-progress one is the exception and wants the same key sent again shortly.
| error.code | HTTP | What it means |
|---|---|---|
Transactions:StandaloneTokenCreateIdempotencyKeyReused |
409 | This key was already used for a different payment method, and replaying would hand back a token for the wrong card. Nothing was minted. Send a fresh key for the new card, or resend the original payment details unchanged. |
Transactions:StandaloneTokenCreateIdempotencyInProgress |
409 | The original create under this key is still running. Retry shortly with the same key to receive the original token. |
Transactions:StandaloneTokenCreateIdempotencyTokenGone |
409 | The key's original create produced a token that no longer exists, so the original response can't be rebuilt. Nothing was minted. Use a new key. |
Transactions:StandaloneTokenCreateIdempotencyStoreUnavailable |
429 | The deduplication check couldn't complete, so the create was refused rather than run unprotected. Nothing was minted, so this is safe to retry. |
Deduplication is a merchant setting on two of the three
On the transaction create and the hosted payment page session create, deduplication is off until someone turns it on for your merchant, through one setting that governs both. It's set on the merchant itself, or as a default across a provider's whole portfolio that applies to every merchant which hasn't set the value. A value set on the merchant always wins, in both directions. While it's off, a key you send to either of those two is accepted and stored but doesn't deduplicate: the create returns a normal 2xx with idempotencyStatus of KeyIgnored, and a repeat send creates again. A successful response from those two is therefore not confirmation that WinkPG is deduplicating, which is the one thing to be careful about here. Check for KeyIgnored on your first call against a new merchant, and raise it with your integration contact rather than treating the result as protected.
The standalone token create isn't behind that setting, and has no setting of its own. A key you send there deduplicates on the first call, for every merchant, and KeyIgnored isn't a value it can return. If you're writing a bulk vaulting or re-vault job, that's the surface you're on: there's nothing for an operator to enable, and nothing to build around a gate that isn't there.
On the transaction create, the key you send is stored with the transaction whatever the setting says, and a lookup-by-idempotency-key operation returns the transaction a given key produced for a merchant. Wire that into your retry path: before re-sending after a timeout, ask whether the key already produced a transaction. The session create and the token create have no equivalent lookup, so on those two the create response is where you learn the outcome. Read idempotencyStatus, and treat a 409 as the answer rather than as a reason to send the same request again under a new key.
Paging a list
A list operation pages with a continuation token: you carry a token forward instead of counting how many rows to skip. Send maxResultCount to size the page, and leave continuationToken off the first request.
POST /api/transactions/list/continuation
{ "maxResultCount": 50 }
POST /api/transactions/list/continuation
{ "maxResultCount": 50, "continuationToken": "W3sidG9rZW4iOiIrUklEOn4..." }{
"items": [],
"nextContinuationToken": "W3sidG9rZW4iOiIrUklEOn4...",
"approxTotalCount": 1284,
"pageItemCount": 50,
"retrievedAt": "2026-08-21T14:30:00Z"
}- Read nextContinuationToken from the response. A value means there's another page: send it back as continuationToken on the next request. Null means you've reached the end, and an empty string means the same, so test for both.
- Hold every other parameter constant for the whole sequence. A token resumes one specific query rather than addressing a position, so changing the filter, the sort, includeDeleted, or any other query parameter while carrying a token returns 400 on continuationToken instead of a page from a different result set. To change any of them, start again with no token. Only maxResultCount and includeApproxTotalCount can change between pages.
- Treat the token as opaque. It's a string to hand back, not a cursor to parse, build, or store between sessions. Where the operation takes it in the query string, URL-encode it.
- There's no page number and no way to step backward. Walk forward, or narrow the filter.
- Send maxResultCount rather than relying on the default, which varies by operation. The platform accepts 1 through 1000. Ask for more and some operations clamp to 1000 while others answer 400, so stay inside the range.
- approxTotalCount is an approximation, for display rather than arithmetic. Exact counts across partitions are expensive, so send includeApproxTotalCount as false to skip the count query, read the count once from the first page, and reuse it for the rest of the run.
Check which shape an operation takes
- Most continuation operations are a POST carrying a JSON body, so the fields are camelCase: continuationToken and maxResultCount. A few are a GET carrying a query string, which binds from the property names, so those are PascalCase: ContinuationToken and MaxResultCount. The reference states which form an operation takes.
- The route segment has more than one spelling too. list/continuation, continuation-list and continuation all appear, so read the operation rather than composing the address yourself.
- An older offset-paged list is still published on many resources, declaring SkipCount alongside MaxResultCount. Where a resource has a continuation operation, that's the one to build against.
- On transactions, a SkipCount above zero is refused with 400, and the message names the continuation operation to use instead. Elsewhere an offset list still answers, so treat a 200 as no evidence that offset paging is the intended path.
- Offset paging gets more expensive the deeper you go, because the store charges for the rows it skips as well as the rows it returns. For a first page it's fine. To walk a whole collection, use a continuation operation.
Timestamps
Every instant the API stores and returns is UTC. That part never varies. The string form does vary across the API, so the two are worth stating apart.
"creationTime": "2026-08-21T14:30:00Z"
"dueDate": "2026-08-21T14:30:00+00:00"- Some fields write a UTC instant with a Z suffix and others write an explicit +00:00 offset. Both name the same moment. Which one you get follows the type behind the field, and both appear across the API, so don't build against a single shape.
- Always send an explicit offset. Write 2026-08-21T14:30:00Z or 2026-08-21T14:30:00+00:00, and never a bare 2026-08-21T14:30:00. Offset-less values aren't normalized the same way on every field, so stating the offset is what removes the question.
- Always parse with an offset-aware type: DateTimeOffset in .NET, an aware datetime in Python, OffsetDateTime in Java. Parsing into a naive local type is where a correct payload turns into the wrong hour.
- Compare instants, not strings. A Z value and a +00:00 value for the same moment aren't equal as text, so parse both sides before you compare or sort.
- Convert to a person's own timezone at the point of display only. What you store and what you send stays UTC.
Amounts and currency
Amounts are decimal numbers in the currency's major unit. Send 49.00 for forty-nine dollars, not 4900.
- A JSON number rather than a string, so 49.00 and 49 are the same value. Use your language's decimal type rather than a binary float, for the reason every money guide gives. The specification renders the type as a double, so check what your generated client produced.
- Major units throughout. There's no minor-unit representation anywhere in the API, so there's no conversion to do on your side.
- Send two decimal places. Money is handled to two places throughout, and the processor adapters assume two, but nothing rejects a third at the boundary today. Don't rely on a third place surviving the trip.
- Zero-decimal currencies such as JPY have no special handling. Treat the API as two-decimal, and raise anything else with your integration contact rather than assuming it converts.
- No amount may be negative. Beyond that the floor depends on the transaction type: a sale needs an amount above zero, an authorization allows zero, and a balance inquiry must carry no amount at all. There's no maximum.
- On a transaction create, amounts sit under invoiceData.amounts. base is the charge, and tax, tip, shipping, surcharge, convenience and cashback are the components that can accompany it.
- total is computed from those components rather than taken from you. A total you send is treated as a checksum over the components and a disagreement is reported, so it's worth sending. Amounts the gateway adds later, such as an assessed surcharge, sit outside that check, so the final authorized total can exceed the one you asserted. Read the amounts back off the create response.
- A transaction carries no currency field. It settles in the currency configured for its merchant, so currency is a property of the merchant rather than of the request.
- Where currency is a field, on an invoice or a contract plan, it's an ISO 4217 alpha-3 code such as USD. Send it in uppercase: some paths normalize it for you and others store it as sent.
- Four spellings exist across the API: currency on most resources, currencyCode on contract plans, amountCurrency on usage reporting, and defaultCurrency on reseller billing preferences. The reference states which one an operation takes.
Errors
When a call fails, WinkPG answers with one envelope shape carrying a code you can branch on, rather than prose you'd have to match. Every code the API can return, and the shape that carries it, is on the error reference. Error codes
Correlation ids
Every response carries an X-Correlation-Id header, and every error envelope repeats it as error.data.correlationId. Quote it when you contact support: it's how a single request is found in the platform's logs, and an unexpected server fault carries no code that could.
HTTP/1.1 500 Internal Server Error
Content-Type: application/json
X-Correlation-Id: 0123456789abcdef0123456789abcdef
{
"error": {
"code": null,
"message": "An internal error occurred during your request!",
"details": null,
"data": {
"correlationId": "0123456789abcdef0123456789abcdef"
},
"validationErrors": null
}
}- The header is on every response, successful or not. Log it beside your own request id, and you can trace any call later without having kept the body.
- You can send your own X-Correlation-Id on a request, and the platform uses it instead of generating one. Keep it short and printable: surrounding spaces and control characters are removed before it's repeated in an error body, and a value longer than 128 characters isn't repeated there at all.
- error.data can carry other entries on some business errors. Read correlationId by name rather than assuming it's the only key.
- A rate-limit refusal (429) answers with a problem details body rather than the error envelope, so it has no error.data. Read the id from its X-Correlation-Id header instead.
- The request log in the portal shows each call's correlation id, so a failed call can be found from the id alone.