Merchant Integration Contract
Every requirement your integration must satisfy - request signing, idempotency, transfer directions, status codes and a zero-downtime rollout - with worked examples in PHP, Node and Python.
Jump to the integration contract ↓Developers
Build on the global payment protocol. Two integration purposes, two settlement processes — declared at registration and enforced by the platform.
1. Declare your purpose
When you register an API key you choose what you are building. That choice decides which endpoints you can call, how settlement works, and what your own system must provide in return. A key may carry one purpose or both.
Goods & services
payments
Customers buy a product or service from you and pay in VeightCoin™. Settlement runs through Atomic Escrow, exactly as it does for a VeightPay™ storefront order.
In-platform tokens
platform_tokens
Customers buy credits, chips, coins or entries used inside your own platform. VeightCoin™ moves between wallets; your platform mirrors the movement in its own balances.
Unsure which applies? Ask whether the customer ends up holding something you deliver (goods and services) or a balance inside your platform (in-platform tokens). A game selling tournament entries is the second. A shop selling headphones is the first. An application that does both selects both.
In-platform tokens carries an obligation
You must expose an endpoint VeightPay™ calls to authorise every movement. Without it your integration cannot complete a single transfer. See your verification endpoint before committing to this path.
2. Two sets of credentials
These are different things, used at different moments. Confusing them is the most common integration failure.
| Credential | Identifies | Used for |
|---|---|---|
| API key + secret plus signing key |
Your application | Server-to-server calls: initialising payments, signing requests, receiving webhooks |
| OAuth2 client id + secret | Your application acting on behalf of a user | Reading a user's wallet balance and moving their VeightCoin™, with their consent |
Both are issued together when you create an API key. The client id is shown permanently in your dashboard; both secrets are shown once. If you lose the OAuth client secret, regenerate it — there is no way to read it back.
Regenerating breaks your integration until you redeploy
The client secret authenticates your application at the token endpoint. Existing access tokens keep working, but no new authorization code can be exchanged until the new secret is live on your side.
3. Goods and services
Escrow settlement
Payment is initialised server-to-server with your API key. VeightCoin™ is held in Atomic Escrow from the moment the customer pays until delivery is confirmed. Confirmation is manual: the buyer confirms receipt, or the confirmation window expires and escrow releases automatically. Both parties are notified at every step.
The protocol does not handle shipping or delivery tracking. It holds the funds and releases them.
POST /api/merchant/payment/initialize
Authorization: Bearer <api_key>
X-API-Signature: <hmac_sha256(raw_body, signing_key)>
No OAuth2 is required here. The customer authorises the payment on VeightPay™'s own hosted flow, not through a wallet link.
4. In-platform tokens
Paired ledgers
Two ledgers move together: VeightCoin™ between VeightPay™ wallets, and your own in-platform balance between your accounts. Every operation touches both, and the two must agree afterwards.
| Operation | VeightCoin™ moves | Your platform | Who authorises |
|---|---|---|---|
DepositPOST /api/v1/add |
user → merchant | Credit the user's in-platform balance | The user, via the consent screen |
PayoutPOST /api/v1/withdrawal |
merchant → user | Debit the user's in-platform balance | You, manually or automatically |
Read the direction column carefully. A deposit moves VeightCoin™ away from the user and gives them platform credit in exchange. A payout is the reverse.
5. Deposit — the user pays in
- The user chooses an amount on your platform. Nothing has moved yet.
- You send them to VeightPay™ to authorise, using the OAuth2 flow below. If their wallet is already linked, you hold an access token for them.
- VeightPay™ calls your verification endpoint. If you decline or do not answer, nothing moves.
- VeightCoin™ moves from the user's wallet to yours. Both wallets are locked and the balance checked; the transfer completes in full or not at all.
- On a success response, credit the user's in-platform balance with the same amount.
POST /api/v1/add
Authorization: Bearer <user_access_token>
X-API-Signature: <hmac_sha256(raw_body, signing_key)>
Idempotency-Key: <your_unique_reference>
{ "amount": "25.00" }
Do not credit before you have a success response
The transfer is refused with 400 Insufficient funds. if the user's wallet cannot
cover the amount. Wallets never go negative, and a refused transfer moves nothing on either side.
6. Payout — the user cashes out
You approve this, not the user — manually, automatically, or both, however you configure your platform. Because you are the approving party, you must check that the user holds enough eligible in-platform balance. VeightPay™ cannot see your balances and will not check them for you. It checks that your VeightPay™ wallet can cover the payout.
- The user requests a payout. You verify their in-platform balance is sufficient and eligible under your own rules.
- You approve, by hand or by rule.
- You debit their in-platform balance to your own account, so your books balance before any VeightCoin™ moves.
- VeightPay™ calls your verification endpoint to confirm the payout is authorised.
- VeightCoin™ moves from your wallet to theirs.
POST /api/v1/withdrawal
Authorization: Bearer <user_access_token>
X-API-Signature: <hmac_sha256(raw_body, signing_key)>
Idempotency-Key: <your_unique_reference>
{ "amount": "25.00" }
The signature is required, not optional
A payout spends your wallet. A user's access token proves the user consented to your app touching their wallet — it grants no authority over yours. Requiring your API signature means a stolen user token is not enough to drain your balance.
Keep your wallet funded
Payouts come from your VeightPay™ wallet. If it cannot cover the amount the transfer is refused, leaving your user with a debited platform balance and no VeightCoin™ — check the response and reverse your own debit if it fails.
7. Your verification endpoint
In-platform token integrations must expose an endpoint VeightPay™ calls before completing any transfer. It keeps both ledgers in step: we do not move VeightCoin™ unless your system confirms it expects the movement. It also carries your authorisation for payouts.
POST https://your-app.example/veightpay/verify
X-VeightPay-Signature: <hmac_sha256(raw_body, signing_key)>
X-VeightPay-Timestamp: 1787512201
{
"event_id": "...",
"operation": "deposit",
"idempotency_key": "...",
"amount": "25.00",
"user_id": "...",
"timestamp": 1787512201
}
Respond 200:
{ "approved": true, "reference": "your-internal-ref" }
- Verify the signature on every call before acting on the contents. The timestamp is inside the signed body — reject anything more than five minutes old, or a captured request replays forever.
- Approval must be explicit. A bare
200does not count. A misconfigured catch-all route returns 200 for anything, so we require"approved": true. - Answer within 5 seconds. A user is watching a spinner; slower and you have traded a security control for an abandoned checkout.
- Expect at most two retries — 1s then 3s — on timeout, transport failure or 5xx only, never on a clean refusal. Each carries the same
idempotency_key, so return your cached answer rather than treating it as a new authorisation. - No answer means no transfer. We fail closed.
operation |
Direction | Sent before |
|---|---|---|
deposit |
customer wallet → your wallet | /api/v1/add completes |
payout |
your wallet → customer wallet | /api/v1/withdrawal completes |
Record your intent before you call us — and commit it
An endpoint that answers "approved": true to everything satisfies this contract and
protects nothing. The value of the call is that you can say no to a movement you did not originate.
Before each transfer, write a record keyed on the idempotency_key you are about to
send — the operation, the amount, who it is for. Approve a verification call only when it matches one.
A transfer with no matching record is a transfer you did not ask for.
Commit that record before you call us. Our verification request arrives as a separate HTTP call on a separate database connection. A row written inside a transaction you have not yet committed is invisible to it — the transfer is refused for having no intent, and your rollback then erases the evidence of why.
Settlement is confirmed separately
Verification authorises; it does not confirm. After the transfer completes we send a signed settlement
webhook with the final outcome. If you miss it, query the status endpoint by idempotency_key
to reconcile — never assume from the verify call alone that money moved.
The settlement webhook is delivered to your transfer settlement webhook URL, which is a separate field from your payment webhook URL — so transfers and checkout events can be handled by different services. Point both fields at the same address if you would rather handle them together. Both are set on your API key page.
POST <your transfer settlement webhook URL>
X-VeightPay-Signature: <hmac_sha256(raw_body, signing_key)>
X-VeightPay-Event: transfer.settled
{
"event": "transfer.settled",
"idempotency_key": "...",
"operation": "deposit",
"amount": "25.00",
"status": "completed",
"transaction_reference": "...",
"user_id": "...",
"timestamp": 1787512201
}
Reconciling a missed settlement
Query any transfer with the idempotency key you sent. Authenticated with your REST API key, and scoped to your own transfers — you cannot see another merchant's.
GET https://www.veightpay.com/api/v1/transfers/<idempotency_key>
X-API-Key: <your_api_key>
{
"success": true,
"data": {
"idempotency_key": "...",
"operation": "payout",
"status": "completed",
"status_code": 200,
"transaction_reference": "...",
"amount": "25.00"
}
}
status | Meaning |
|---|---|
completed | The transfer settled. transaction_reference is your receipt. |
failed | A settled refusal. status_code and message say why. Retrying the same key returns this same answer. |
in_progress | Claimed but not yet resolved. Wait and query again rather than starting a new transfer. |
8. Linking a wallet with OAuth2
A standard authorization-code flow. The two parts people get wrong are the redirect URI and the state parameter.
- Generate a state value — random and unguessable — and store it in the user's session. This is what stops an attacker feeding your callback a code of their choosing.
- Send the user to the authorize endpoint with your client id, registered redirect URI,
response_type=code, the scopes you need, and the state. - The user approves. They see exactly which permissions you requested and choose whether to grant them.
- Verify the returned state against the session value and abandon the flow if they differ. Only then exchange the code.
- Store the tokens against the user. You receive an access token, a refresh token and an expiry.
GET https://veightpay.com/oauth/authorize
?client_id=<your_client_id>
&redirect_uri=<your_registered_uri>
&response_type=code
&scope=get-balance+withdraw-balance
&state=<state>
POST https://veightpay.com/oauth/token
grant_type=authorization_code
client_id=<your_client_id>
client_secret=<your_client_secret>
redirect_uri=<the same URI, byte for byte>
code=<code>
Redirect URIs are matched exactly
Byte for byte, against the URIs registered on your API key. There is no normalisation:
https://app.example/cb and https://app.example/cb/
are different URIs, and so are the www and apex forms of the same host.
Register every variant you will send, and send the same one again at the token step.
Matching the wallet to the right person
A linked wallet is where a user's money leaves your platform. Before you store that link, confirm the VeightPay™ account belongs to the same person as the account on your side.
POST /api/v1/balance returns the account holder's name and email under the
get-balance scope. Compare both against your own record, and refuse the link
if either differs.
POST /api/v1/balance
Authorization: Bearer <user_access_token>
{
"data": {
"name": "Ada Lovelace",
"email": "ada@example.com",
"phone": "+44...",
"balance": "125.00"
}
}
Refuse a mismatch, do not warn about it
Storing a link you already know points at someone else is worse than refusing it: every payout after that goes to the wrong person, and the user who was paid has no reason to report it. Compare with surrounding whitespace trimmed and case ignored — ada lovelace and Ada Lovelace are the same person; A Lovelace is not.
Where the link comes from
A user who completes a deposit has already signed in to VeightPay™ and consented, so their wallet is linked from that moment — there is no separate linking step to ask them for.
At payout time, check whether a link exists before you debit anything. If it does not, send the user through the authorization flow rather than failing the request: a payout with no linked wallet has nowhere to send money, and approving one debits your books against a transfer that cannot happen.
Check identity again if the link predates your check
If you add this verification after wallets are already linked, those older links carry no proof of a match. Verify them at the next payout rather than trusting them, and record when each link was checked so the answer is durable.
9. Scopes
Scopes describe what a user is granting you. Request only what you need — the consent screen lists them, and a narrower request is more likely to be approved and less damaging if a token leaks.
| Scope | Grants | Availability |
|---|---|---|
get-balance |
Read the user's VeightPay™ account: name, email, phone and wallet balance | Default |
withdraw-balance |
Move VeightCoin™ from your wallet to the user's | Default |
add-balance |
Move VeightCoin™ from the user's wallet to yours | Granted on request |
Each API key carries an allowlist of the scopes it may request. Asking for a scope outside your allowlist fails at the authorize step, before any code is issued — not later with a confusing permission error.
Changing a key's purpose changes its allowlist. Adding a purpose has no effect on existing
tokens: new scopes become requestable, but a token never widens on its own — the user must consent again.
Removing a purpose takes effect immediately, including for tokens already issued, and we notify
you so you learn from a message rather than a wave of 401s.
10. Idempotency, revocation and failures
Idempotency
Send an Idempotency-Key on every transfer, unique per logical operation and stable
across retries — your own reference for that deposit or payout is usually right. Without one, a timed-out
request leaves you unable to tell whether the transfer happened: retrying may move the money twice, not retrying
may leave a user paid but uncredited.
Revocation
Revoking or deleting an API key revokes its OAuth2 client and every token issued through it, immediately.
A user can also withdraw consent from their own account without telling you — treat a
401 on a previously working token as "this user unlinked", not as an outage.
Common failures
| Symptom | Cause |
|---|---|
invalid_client |
The client id does not exist — usually a stale value in a cached config. Clear your config cache after changing credentials. |
invalid_scope |
You requested a scope outside your key's allowlist, or one that does not exist. |
Authorize returns 401 |
The redirect URI does not match a registered one. We refuse rather than redirect, so the endpoint cannot be used to bounce users anywhere. |
| Callback throws before the code is read | Your state check failed — usually the callback was opened directly rather than reached through the flow. Handle it as a user-facing error, not an exception. |
400 Insufficient funds. |
The paying wallet cannot cover the amount. On a payout, that wallet is yours. |
Documentation and support
Endpoint reference, request and response examples and your credentials live inside your dashboard once you register as a merchant. Need help? Use in-app support or the Help Center.
VeightPay · Merchant integration
Merchant Integration Contract
What every request to the VeightPay API must carry, and what your platform must guarantee in return. These requirements apply to all merchants and are enforced per API key.
Decide what you are integrating
VeightPay supports two integration purposes. They behave differently and you must declare which applies at registration — the wrong choice produces the wrong settlement behaviour, not merely the wrong documentation.
Purpose 1 — Goods and services
Your customer buys a product or service from you and pays in VC, with VeightPay as the payment provider.
Settlement runs through the VeightPay escrow system, identical to storefront transactions. You do not move balances yourself.
Purpose 2 — Platform credit
Your customer buys in-platform tokens, credits or currency used inside your own application.
No escrow. VC moves directly between wallets, and you are responsible for keeping your in-platform balances in step.
An application may use both purposes. Each payment request must then state which one applies, so the correct settlement path is selected. Purpose is changeable after registration, but changing it does not retroactively alter transactions already settled under the previous purpose.
Understand your four credentials
These are distinct and not interchangeable. Most integration failures come from using one where another was required.
| Credential | Sent as | What it is for |
|---|---|---|
api_keyvp_… |
X-API-Key |
Identifies your integration on REST calls. Not secret on its own. |
api_secret |
Never transmitted | Stored hashed by VeightPay. Used for credential verification only. It is not the signing key and cannot produce a valid signature. |
signing_key64 hex chars |
Never transmitted | The HMAC key used to sign request bodies. Shown once, at generation, on your API key page. |
| OAuth2 client ID & secret | Authorization flow | A separate credential pair for the transfer endpoints. The client ID is public; the secret is confidential and exchanges authorization codes for access tokens. |
The OAuth2 client secret and the REST API secret are different credentials serving different flows. Configuring one where the other belongs fails in ways that look like an authentication bug rather than a configuration error.
Sign every write request
Every POST, PUT, PATCH and DELETE must carry an X-API-Signature header. The signature is a hex-encoded HMAC-SHA256 of the exact raw request body, keyed with your signing key.
X-API-Signature = hex( HMAC_SHA256( raw_request_body, signing_key ) )
Sign the exact bytes you transmit. Serialise your payload to a string once, sign that string, and send that same string as the body.
If you sign a serialisation and then let your HTTP client re-serialise the object, key order, whitespace or slash escaping can differ by a single byte and the signature will not match. The server compares against the raw body it received, byte for byte.
$body = json_encode($payload, JSON_UNESCAPED_SLASHES);
$signature = hash_hmac('sha256', $body, config('services.veightpay.signing_key'));
$response = Http::withHeaders([
'X-API-Key' => config('services.veightpay.api_key'),
'X-API-Signature' => $signature,
'Idempotency-Key' => (string) Str::uuid(),
'Accept' => 'application/json',
])
// withBody(), not post($url, $payload) - the array form re-encodes
// and can produce different bytes than the ones you signed.
->withBody($body, 'application/json')
->post($url);
const body = JSON.stringify(payload);
const signature = crypto
.createHmac('sha256', process.env.VEIGHTPAY_SIGNING_KEY)
.update(body, 'utf8')
.digest('hex');
await fetch(url, {
method: 'POST',
headers: {
'X-API-Key': process.env.VEIGHTPAY_API_KEY,
'X-API-Signature': signature,
'Idempotency-Key': crypto.randomUUID(),
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body, // the same string that was signed
});
body = json.dumps(payload, separators=(",", ":"))
signature = hmac.new(
signing_key.encode(), body.encode(), hashlib.sha256
).hexdigest()
requests.post(
url,
data=body, # data=, not json= - json= would re-encode
headers={
"X-API-Key": api_key,
"X-API-Signature": signature,
"Idempotency-Key": str(uuid.uuid4()),
"Content-Type": "application/json",
"Accept": "application/json",
},
)
Verifying inbound webhooks
Webhooks VeightPay sends to you are signed the same way, over the raw body of the delivery. Compare using a constant-time function — hash_equals in PHP, crypto.timingSafeEqual in Node, hmac.compare_digest in Python — never ==.
Send an idempotency key on transfers
Transfer requests require an Idempotency-Key header. It is mandatory, not advisory: a request without one is rejected.
Generate one UUID per logical operation and reuse it on every retry of that same operation. A new key means a new transfer.
| Situation | Response |
|---|---|
| First use of the key | The transfer executes. |
| Retry, same key, same body | The original outcome is returned. No second transfer occurs. |
| Same key, different body | 409 — the key is already bound to a different request. This indicates a bug in your client. |
| Retry while the first is still running | 409 — a request with this key is in flight. Wait and retry. |
| Original failed with a server error | The key is released, so a genuine retry can proceed. |
A request that times out leaves you unable to tell whether the transfer happened. Retrying might move the money twice; not retrying might leave a customer debited and uncredited. The idempotency key removes the guess. A unique database index enforces it, so a concurrent duplicate is impossible rather than merely unlikely.
Send the customer to hosted checkout
payments/create returns a payment_url. Open it as a top-level navigation. Never in an iframe, a modal frame, or an embedded view you control.
// Correct - the customer leaves your site
window.location.assign(paymentUrl);
// If your UI itself runs inside a frame, break out of it
(window.top || window).location.assign(paymentUrl);
The checkout page sends X-Frame-Options: SAMEORIGIN. A browser asked to render it inside your frame refuses and shows a blocked-content message — and throws no error your JavaScript can catch, so it fails silently from your side.
This is deliberate and not relaxed per merchant. The customer signs in to VeightPay during checkout, and they must be able to see the address bar to know where their credentials are going. That visibility is the security model of hosted checkout.
Design for the round trip
Checkout is not an in-page experience. The customer leaves your site, authenticates with VeightPay, pays, and returns to the return_url or cancel_url you supplied. Assume any page state you were holding is gone when they come back — carry what you need in order_id or metadata, both of which are returned to you on the webhook.
A customer arriving at your return_url means their browser came back. It does not mean the payment succeeded — they may have closed the tab mid-flow, or returned before settlement finished. Treat the return as a cue to show pending status, and credit only on the verified payment.completed webhook.
Authorize a transfer
The transfer endpoints use OAuth2 authorization code flow, not your REST key. Send the customer to:
GET https://www.veightpay.com/oauth/authorize
?client_id=YOUR_CLIENT_ID
&redirect_uri=YOUR_REGISTERED_URI
&response_type=code
&scope=add-balance
&state=RANDOM_UNGUESSABLE_VALUE
Exchange the returned code at /oauth/token for an access token, then send that token as Authorization: Bearer … on the transfer call.
The redirect URI is matched exactly. A trailing slash, a different scheme, or an added query parameter makes it a different URI and the request is rejected.
Always send state, and verify it on return. It is your only defence against a forged authorization callback.
A bearer token is not sufficient on its own. Transfer requests must also carry X-API-Signature and Idempotency-Key, because a payout spends the merchant's wallet — a stolen customer token must not be enough to drain it. The signature proves the request originated from your server.
The transfer endpoints authenticate the customer with the bearer token, then look up the signing key from the API key linked to that OAuth client. Those two records must be associated, and the association is made on your API key page.
If they are not associated, every transfer is refused with 403 No active API key is linked to this OAuth client. — before the signature is even examined. The credentials are individually valid; it is the link between them that is missing, which is why the message names it explicitly rather than reporting a signature failure.
Implement the two transfer directions correctly
add — customer deposit
customer wallet → merchant wallet
The customer approves through the VeightPay consent screen.
On a 200, credit the customer's in-platform balance by the same amount.
withdrawal — customer payout
merchant wallet → customer wallet
You approve, manually or automatically as you configure.
Verify and debit the customer's in-platform balance first, then call VeightPay.
VeightPay cannot see your in-platform balances. On a payout, only your system knows whether the customer is entitled to the amount — so only your system can authorise it. Check the balance, debit it, then call the API. Never call first and reconcile afterwards.
Neither endpoint creates or destroys VC. Both move it between two existing wallets, and the server asserts that the sum of the two wallets is unchanged before committing. A payout exceeding the merchant wallet balance is refused with 400 Insufficient funds. rather than being allowed to go negative.
Handle these responses
| Code | Meaning | What to do |
|---|---|---|
| 200 | Success. | Record the transaction reference. |
| 400 | A settled refusal — insufficient funds, missing idempotency key, invalid amount. | Do not retry unchanged. Fix the request or surface the reason. |
| 401 | Unauthenticated, expired token, or a missing or invalid signature. | Refresh the token or fix your signing. Retrying unchanged will not help. |
| 403 | No active API key linked to this client, or no signing key configured. | Check your API key page. Generate a signing key if absent. |
| 409 | Idempotency conflict — reused key with a different body, or a request in flight. | Wait and retry with the same key, or fix the duplicate key bug. |
| 422 | Validation failed. The errors object names the offending fields. | Correct the payload. |
| 429 | Rate limit exceeded. | Back off and retry after the window. See below. |
| 500 | An unexpected server error. | Safe to retry with the same idempotency key. |
The API previously returned 500 for authentication, authorization and validation failures as well as genuine errors. If your client treats every non-200 as retryable, or branches on the response body text rather than the status code, revisit that logic — the codes above are now accurate and distinguishable.
Rate limits
Each API key has a per-minute request allowance, shown on your API key page. Transfers are counted in a separate bucket from other REST calls, so status polling cannot exhaust the budget a payout needs. Only signed requests consume the transfer allowance.
Roll out signing without downtime
Signature enforcement is set per API key. While it is optional, unsigned writes are accepted — but any signature you do send is still verified. That gives you a safe sequence:
- Generate a signing key on your API key page and store it in your secrets manager. It is displayed once.
- Deploy your signing code while enforcement is still optional. Nothing breaks if it is wrong yet.
- Watch for 401 responses. Because supplied signatures are always verified, a wrong signature fails immediately and visibly — while unsigned traffic still succeeds.
- Confirm a clean run with no signature failures across your normal traffic.
- Turn enforcement on from the API key page. Unsigned writes are rejected from that moment.
Enforcement can be switched back off if a deployment has to be rolled back, so a bad release does not leave you unable to transact. It cannot be switched on without a signing key present — that would reject every write.
Before you go live
- Integration purpose declared and matching how you actually settle.
- Signing key stored securely — secrets manager or environment, never in source control.
- Raw body signed and transmitted unchanged, with no re-serialisation between signing and sending.
- Idempotency key per logical operation, reused across retries of that operation.
- Checkout opened as a top-level navigation, never inside a frame.
- Return and cancel URLs handle an early arrival — show status, credit only on the webhook.
- Redirect URI registered exactly as your application sends it.
stategenerated and verified on every authorization round trip.- Inbound webhook signatures verified with a constant-time comparison.
- Status codes branched on individually, not treated as one failure class.
- In-platform balance checked and debited before payout, for purpose 2 integrations.
- Retries carry the original idempotency key, never a fresh one.