Skip to content
Developers · Cards

Card deposits

Visa and Mastercard deposits from customers anywhere. The customer enters the card and completes 3-D Secure on the card provider's secure payment page we send them to; you get a signed webhook and a status call.

API host
https://api2.payeeglobal.com
Create
POST /v2/upi/payin, payment_method: card
Cards
Visa and Mastercard, with 3-D Secure
Card data
Entered on the card provider's secure page, never on yours or ours
Currencies and limits
Set per account, shown in the console
Result
Signed webhook and the status call
On this page
Start

Integration path

Card deposits take Visa and Mastercard from customers anywhere. Your server creates the deposit, the customer enters the card and completes 3-D Secure on the card provider's secure payment page we send them to, and the result reaches you as a signed webhook and through the status call. Your systems never receive card details.

  1. Get your keys. Your API key and encryption key are in the console at https://app2.payeeglobal.com, under Developers, then API keys. Getting started covers keys, rotation, the IP allow-list and domain approval.
  2. Encrypt the deposit. AES-256-CBC with your encryption key and a fixed IV; the authentication guide has the code and a test vector. The programs below include it.
  3. Create the deposit with POST /v2/upi/payin and "payment_method": "card". Create a card deposit.
  4. Redirect the customer to authenticate_url, unchanged. The secure payment page.
  5. Receive the signed webhook and verify it. Webhook; the webhooks guide has receivers in Node and PHP.
  6. Check any deposit with the status call when you need to.
  7. Test with small real amounts, then launch. Testing.
API host https://api2.payeeglobal.com
Create a card deposit POST /v2/upi/payin, encrypted body, "payment_method": "card"
Check a deposit POST /v2/upi/status, plain JSON
Authentication Authorization: Bearer <API key> on every call
Cards Visa and Mastercard, with 3-D Secure, on the card provider's secure payment page
Result A signed webhook to your webhook_url, and the status call
Currencies and limits Set per account; the console shows yours under Payments, then Payment methods

The create path contains upi for historical reasons. It is the card deposit call whenever the fields carry "payment_method": "card".

The flow

Text
 Customer           Your server                Payee Global API          Secure payment page
    |                    |                            |                            |
    | 1. deposit 50 USD  |                            |                            |
    |------------------->| 2. POST /v2/upi/payin      |                            |
    |                    |--------------------------->|                            |
    |                    | 3. "authenticate"          |                            |
    |                    |    + authenticate_url      |                            |
    |                    |<---------------------------|                            |
    | 4. redirect to authenticate_url                 |                            |
    |<-------------------|                            |                            |
    | 5. card details and 3-D Secure                                               |
    |----------------------------------------------------------------------------->|
    | 6. may come back to your response_url (not proof of payment)                 |
    |------------------->|                            |                            |
    |                    | 7. signed webhook          |                            |
    |                    |<---------------------------|                            |
    |                    | 8. POST /v2/upi/status, any time                        |
    |                    |--------------------------->|                            |
Start

Getting started

Onboarding

Credentials are issued after onboarding: we review your business, agree the terms in writing and set up your account. When your account is ready you receive a sign-in for the merchant console at https://app2.payeeglobal.com.

Where things are in the console

You need In the console
Your API key and encryption key Developers, then API keys
Your webhook signing key, the test event and recent deliveries Developers, then Webhooks
Your methods, currencies and limits Payments, then Payment methods
The server addresses allowed to call the API IP whitelist
Deposits, refunds and chargebacks Payin
Pay-outs Payout
Balances and statement Reports, then Balance & statement

Keys

Key What it looks like Used for
API key, shown as Live key in the console 36 characters Authorization: Bearer <API key> on every call
Encryption key 44 characters of base64, which decode to 32 bytes encrypting the body of create calls; your webhook signing key is derived from it
Webhook signing key 64 hex characters verifying our webhook signatures; your webhook server needs only this key

The console asks for your password before it shows a key. It also lists a test key; the calls on this page do not use it.

Keep every key on your servers: never in a web page, a mobile app, a support ticket or a chat message.

Rotating keys

You can rotate each key in the console. The old API key and the old encryption key keep working for an overlap window, 24 hours by default, and the console shows until when, so you can switch your servers over. If a key may have leaked, tick Stop the old key now when you rotate: the old key then stops at once.

Rotating the encryption key changes your webhook signing key straight away, with no overlap. Deploy the new signing key to your webhook server as soon as you rotate.

IP allow-list

Send us the public IP addresses of the servers that will call the API. With the allow-list on, a call to create a deposit, check a deposit or create a pay-out from any other address is refused with HTTP 401:

Example
{"status": "unauthorized", "message": "IP address (203.0.113.10) not whitelisted."}

You manage the addresses in the console under IP whitelist.

Domains

Every domain that sends customers to a deposit must be approved before it takes traffic. Send your account manager the list: your checkout domain, each mirror, and the hosts of your response_url and webhook_url. Send new ones ahead of time. We confirm each approval in writing; do not send deposits from a domain we have not confirmed.

Check your key

The balance call needs only your API key, so it is the quickest check of your set-up.

Examples
curl -sS -X POST 'https://api2.payeeglobal.com/v2/transaction/balance' \
  -H "Authorization: Bearer $PG_API_KEY" \
  -H 'Content-Type: application/json'

The samples read your keys from environment variables: PG_API_KEY, PG_ENCRYPTION_KEY and, on your webhook server, PG_WEBHOOK_SIGNING_KEY. A working key answers "status": "success" with your balances; a wrong one answers HTTP 401 with "status": "unauthorized".

Deposits

Create a card deposit

Create a card deposit

POST https://api2.payeeglobal.com/v2/upi/payin, with the fields below encrypted as described in the authentication guide.

Header Required Value
Authorization Yes Bearer <API key>
Content-Type Yes application/json
Idempotency-Key Recommended 1 to 200 printable ASCII characters, unique per deposit; your merchant_order_id is a good choice. See safe retries
X-Api-Status-Codes No strict makes refusals use real HTTP codes
Field Required Format Notes
payment_method Yes card Makes this a card deposit
currency Yes Three capital letters, ISO 4217, for example USD Must be switched on for cards on your account
amount Yes Decimal string, for example "50.00" In currency, within your limits. Digits and one dot: no sign, no thousands separators
merchant_order_id Strongly recommended 3 to 100 letters, digits, dashes or underscores Your reference. Unique across your deposits: a repeat is refused
first_name, last_name Yes 2 to 100 characters each The customer's name
email Yes An email address The customer's email
phone_number No + and 5 to 20 digits The customer's phone
address, city, state, zip No 2 to 250 characters each The customer's billing address. Send them when you have them
country No Two capital letters, ISO 3166-1 The customer's country, for example CA
ip_address No IPv4 or IPv6 Checked when sent; the deposit records the address of the server that calls us
response_url Yes An absolute URL on a public domain Where the customer can come back to your site
webhook_url Yes An absolute URL on a public domain Where we POST the signed webhook for this deposit

Leave out membership_duration, upi and pg_id: they do not apply to card deposits. Fields not listed here are ignored, including order_id and customer_user_id: the reference field is merchant_order_id.

Each program below encrypts the deposit, sends it with an Idempotency-Key, and prints the HTTP status and the answer.

Examples
// Node 18 or later, as an ES module (.mjs). No dependencies.
import crypto from 'node:crypto';

const API_BASE = 'https://api2.payeeglobal.com';
const IV = Buffer.from('1878eadd223d030ad338a354c29203c3', 'hex'); // fixed 16-byte IV

function encrypt(plainJson, encryptionKeyBase64) {
  const key = Buffer.from(encryptionKeyBase64, 'base64');
  if (key.length !== 32) throw new Error('The encryption key must decode to 32 bytes');
  const cipher = crypto.createCipheriv('aes-256-cbc', key, IV); // PKCS#7 padding is the default
  return Buffer.concat([cipher.update(plainJson, 'utf8'), cipher.final()]).toString('base64');
}

const deposit = {
  payment_method: 'card',
  currency: 'USD',
  amount: '50.00',
  merchant_order_id: 'ORDER-10045',
  first_name: 'Alex',
  last_name: 'Morgan',
  email: 'alex.morgan@example.com',
  country: 'CA',
  response_url: 'https://shop.example.com/checkout/return',
  webhook_url: 'https://shop.example.com/webhooks/payee-global',
};

const res = await fetch(`${API_BASE}/v2/upi/payin`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.PG_API_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': deposit.merchant_order_id, // reuse it when you retry this deposit
  },
  body: JSON.stringify({ encrypted_data: encrypt(JSON.stringify(deposit), process.env.PG_ENCRYPTION_KEY) }),
  signal: AbortSignal.timeout(60000),
});
const text = await res.text(); // a 429 or 5xx page from the edge is not JSON
console.log(res.status, text);
// HTTP 200 with "status": "authenticate": store transaction_id, then redirect the customer to authenticate_url.

The answer

A normal answer is HTTP 200 with "status": "authenticate":

Example
{
  "status": "authenticate",
  "authenticate_url": "https://api2.payeeglobal.com/app2/checkout/<token>",
  "merchant_order_id": "ORDER-10045",
  "transaction_id": "TK3V9QX2M1791021600123",
  "amount": "50.00",
  "message": "Enter card details.",
  "currency": "USD",
  "payment_method": "card"
}
Field Meaning
status The outcome of the create call; see the table below
authenticate_url Sends the customer to the card provider's secure payment page for this deposit. Its host and path can change: use it exactly as returned
merchant_order_id Your reference, as sent; null if you sent none
transaction_id Our reference. Store it with your order: webhooks, the status call and support use it
amount The amount, as sent
message Text for people. Do not parse it; branch on status
currency, payment_method The deposit's currency, and card

Outcomes of the create call

status HTTP Meaning What to do
authenticate 200 The deposit is waiting for the customer Redirect the customer to authenticate_url
validation_error 200; 400 in strict mode A field is missing or invalid, the body could not be decrypted, the merchant_order_id was used before, or the currency or amount is outside your settings. Nothing was created Read message and fix the request
unauthorized 401 Key, account or IP address refused Check the key and your allow-list
blocked 200 Refused by a limit or a risk rule, or no card acceptance is set up for your account (No PG account to process payin.) Do not retry the same request. Tell the customer the deposit cannot be made. Tell us if you see No PG account to process payin.
failed 200 The deposit could not be started, for example Transaction error. Offer another attempt later, with a new merchant_order_id. Tell us if it persists

Expect authenticate: a card deposit is paid on the secure page, after the create call. If you see a status not listed here, treat the deposit as not paid and check it with the status call.

Card deposits

Create a card deposit

POSThttps://api2.payeeglobal.com/v2/upi/payin

Creates a card deposit when the fields carry "payment_method": "card". The path contains upi for historical reasons; it is the one create call for every deposit method. The fields are listed above.

Headers

HeaderRequiredValue
AuthorizationYesBearer <API key>
Content-TypeYesapplication/json
Idempotency-KeyRecommended1 to 200 printable ASCII characters, unique per deposit. Reuse it to retry the same deposit safely. See safe retries.
X-Api-Status-CodesNostrict gives real HTTP codes on error answers.

Request fields, before encryption

Serialise these as JSON, encrypt them as described in the authentication guide, and send only the ciphertext.

Fields
{
  "payment_method": "card",
  "currency": "USD",
  "amount": "50.00",
  "merchant_order_id": "ORDER-10045",
  "first_name": "Alex",
  "last_name": "Morgan",
  "email": "alex.morgan@example.com",
  "country": "CA",
  "response_url": "https://shop.example.com/checkout/return",
  "webhook_url": "https://shop.example.com/webhooks/payee-global"
}

Request body sent

Body
{
  "encrypted_data": "<BASE64_CIPHERTEXT>"
}

Answers

The deposit, waiting for the customer; or a refusal. Read status in the body.

{
  "status": "authenticate",
  "authenticate_url": "https://api2.payeeglobal.com/app2/checkout/<token>",
  "merchant_order_id": "ORDER-10045",
  "transaction_id": "TK3V9QX2M1791021600123",
  "amount": "50.00",
  "message": "Enter card details.",
  "currency": "USD",
  "payment_method": "card"
}
Deposits

The secure payment page and 3-D Secure

Send the customer to pay

Redirect the customer to authenticate_url with a full-page redirect: an HTTP 302 from your server, or window.location in the browser. Use the URL exactly as we return it: do not change, rebuild or shorten it, and do not load it in an iframe. It belongs to one deposit.

Card details and 3-D Secure

On the card provider's secure payment page the customer enters the card number, expiry date and security code, and completes 3-D Secure when their bank asks for it: a one-time code, or an approval in the banking app. A bank may also approve without a challenge. There is nothing to send or receive on your side for 3-D Secure.

Your systems never receive card details. You send us the amount, the customer's name and email and your reference; the card is entered only on the card provider's secure payment page. Card data stays out of your pages, your servers and your logs, and out of ours.

The result

The result does not depend on what the customer does next. It reaches you as a signed webhook when the deposit is final. The status call shows the deposit as we have recorded it: a card deposit becomes final there once the card provider has confirmed the outcome to us, which normally happens within minutes of the customer finishing on the payment page. A deposit the customer leaves unfinished stays authenticate until it ends. Reconcile deposits still authenticate 45 minutes after you created them with the status call, and tell us about any you believe were paid: we check them with the card provider.

Return to your site

When the payment ends, the customer can come back to your response_url. The return can carry query parameters such as status, message, order_id and transaction_id, appended with & when your URL already has a query string.

  • They are not signed, and they can be missing. Anyone can type them. Use them only to choose what to show, never to credit a deposit.
  • The customer may come back before the webhook arrives, or not come back at all. Show "We are confirming your payment" until your server has a verified webhook or a status answer, then show the result.
Deposits

Webhook

We POST a signed message to the deposit's webhook_url when it becomes success, failed, blocked or on_hold. Verify X-Webhook-Signature over the raw body with your webhook signing key before you read it: the webhooks guide has the steps, a receiver in Node and PHP, and a test vector.

On the wire the body is compact JSON; shown indented here:

Example
{
  "status": "success",
  "merchant_order_id": "ORDER-10045",
  "transaction_id": "TK3V9QX2M1791021600123",
  "amount": "50.00",
  "message": "Transaction processed successfully.",
  "upi": null,
  "utr": null,
  "currency": "USD",
  "payment_method": "card"
}
Field Meaning
status success, failed, blocked or on_hold. An on_hold deposit is later followed by success or failed
merchant_order_id, transaction_id Your reference and ours
amount The deposit amount, a decimal string. Compare it with your order
message Text for people
upi, utr Always null for card deposits. Ignore them
currency, payment_method The deposit's currency, and card. Treat them as optional: when they are absent, the status call has them

Answer HTTP 200 once the message is verified and stored, and apply each (transaction_id, status) once. A deposit that reached success or failed does not change between the two afterwards. Messages with an event field are notices (test, refund, chargeback), not deposit results.

Deposits

Status and statuses

Check a deposit

POST https://api2.payeeglobal.com/v2/upi/status with a plain JSON body: your merchant_order_id, or our transaction_id. The status call has the code in cURL, Node and PHP.

Example
{
  "status": "success",
  "merchant_order_id": "ORDER-10045",
  "transaction_id": "TK3V9QX2M1791021600123",
  "amount": "50.00",
  "message": "Transaction processed successfully.",
  "upi": null,
  "utr": null,
  "currency": "USD",
  "payment_method": "card"
}

Deposit statuses

status Meaning Final
authenticate Waiting for the customer on the secure payment page No
pending, to_be_confirm The payment is being confirmed No
success Paid Yes
failed Ended without payment: not completed, cancelled or timed out Yes
blocked Refused by a limit or a risk rule Yes
on_hold Held for review; becomes success or failed No
canceled, expired Ended without payment; treat as failed Yes
settled Paid and settled to you Yes
Deposits

Refunds and chargebacks

Refunds

Refunds are made by us, on your request, for the full amount of a successful deposit only: a deposit cannot be partly refunded. Write to support@payeeglobal.com or your account manager with the transaction_id, your merchant_order_id and the reason.

Once the refund is recorded, the status call shows "refund_status": true and refund_date for the deposit, and the refunded amount comes out of your available balance. Your webhook endpoint may also receive a signed notice with "event": "refund". How long the refund takes to reach the customer's card depends on the customer's bank. Refund fees are as set out in your agreement.

Chargebacks

A chargeback happens when the customer disputes a deposit with their bank. We contact you about each one and tell you which documents to send, and by when. The deposit is listed in the console under Payin, then Chargebacks; the status call shows "chargebacks_status": true and chargeback_date; and your webhook endpoint may receive a signed notice with "event": "chargeback". Chargeback fees and thresholds are as set out in your agreement.

Deposits

Currencies, limits and errors

Currencies and limits

Cards work in the currencies switched on for your account, each with your own minimum and maximum per deposit. The console shows them under Payments, then Payment methods. Send the amount in the deposit's own currency, as a string with two decimals: "1700.00", not 1700 or "1,700.00".

status message Meaning
validation_error Payment method card is not enabled for EUR. The currency is not switched on for cards on your account
validation_error Payment method card is not available in EUR yet. No card acceptance takes this currency for your account
validation_error Currency XXX is not supported for card. A currency we do not take for cards
validation_error Invalid currency., Invalid payment_method. Wrong format
validation_error Amount must be at least <min> <currency>., Amount must be at most <max> <currency>. Outside your limits per deposit
blocked Per transaction amount should be more than <min> <currency>., Per transaction amount should be less than <max> <currency>. Outside the limits of the card acceptance on your account
blocked Daily transaction amount limit exceeded. A daily limit on your account is reached

Field errors

Each comes with "status": "validation_error", and nothing is created:

message
First name is required, First name must be between 2 and 100 characters
Last name is required, Last name must be between 2 and 100 characters
Email is required, Invalid email address
Phone number must be 5 to 20 digits, optionally starting with +
Amount is required, Invalid amount format
Merchant order ID must be between 3 and 100 characters
Merchant order ID may contain only letters, numbers, dashes and underscores
Country code must be 2 characters, Country code must be uppercase letters
Response URL is required, Webhook URL is required, Invalid URL
Invalid IP address
Duplicate order_id, field must be unique.
Invalid data encryption., Encrypted data must be a non-empty string.

The errors guide has the HTTP codes, strict mode and the authentication answers.

Guides

Authentication and encryption

Bearer key

Every call carries your API key:

Text
Authorization: Bearer <API key>
Answer HTTP Meaning
{"status": "unauthorized", "message": "Authentication failed."} 401 No Authorization header
{"status": "unauthorized", "message": "Invalid secret key."} 401 Wrong key, or the account is not active for API calls
{"status": "unauthorized", "message": "IP address (...) not whitelisted."} 401 The calling address is not on your allow-list

Which bodies are encrypted

Call Body
POST /v2/upi/payin, create a deposit Encrypted: {"encrypted_data": "<base64>"}
POST /v2/refund, create a pay-out Encrypted: {"encrypted_data": "<base64>"}
POST /v2/upi/status, POST /v2/refund/status, POST /v2/transaction/balance Plain JSON

How to encrypt

  1. Build the request fields as a JSON object and serialise it as UTF-8 text.
  2. Encrypt that text with AES-256-CBC and PKCS#7 padding. The key is your encryption key, base64-decoded to 32 bytes. The IV is the fixed hex value 1878eadd223d030ad338a354c29203c3, 16 bytes, unless your account manager gave you another one.
  3. Base64-encode the ciphertext, standard alphabet with padding.
  4. Send {"encrypted_data": "<base64 ciphertext>"} as the body, with Content-Type: application/json.

If we cannot decrypt the body the answer is {"status": "validation_error", "message": "Invalid data encryption."}; a body without encrypted_data is answered Encrypted data must be a non-empty string. During the overlap after you rotate the encryption key, bodies encrypted with the old key are still accepted.

Examples
// Node 18 or later. No dependencies.
import crypto from 'node:crypto';

const IV = Buffer.from('1878eadd223d030ad338a354c29203c3', 'hex'); // fixed 16-byte IV

export function encrypt(plainJson, encryptionKeyBase64) {
  const key = Buffer.from(encryptionKeyBase64, 'base64');
  if (key.length !== 32) throw new Error('The encryption key must decode to 32 bytes');
  const cipher = crypto.createCipheriv('aes-256-cbc', key, IV); // PKCS#7 padding is the default
  return Buffer.concat([cipher.update(plainJson, 'utf8'), cipher.final()]).toString('base64');
}

// body = JSON.stringify({ encrypted_data: encrypt(JSON.stringify(fields), process.env.PG_ENCRYPTION_KEY) })

Check your code with this test vector

The key below is a published example made of the bytes 0 to 31. Never use it for real traffic.

Encryption key (example) AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=
Plain JSON {"payment_method":"card","currency":"USD","amount":"10.00"}
encrypted_data jByUGovr0j/ox4JXCqqDFOhCi2dx2XT9kKf2x2VZ8cedU7LUUjKW2E3gU/0WwFrAA7SNv5r8rkJK3fwMJLB3Cg==

Your code must produce exactly this encrypted_data from exactly this JSON text, byte for byte. Field order and spacing change the ciphertext, not the meaning: we accept any valid JSON.

Guides

Webhooks

When we send one

We POST a signed JSON message to the webhook_url of the payment when it reaches one of these statuses:

Payment status values sent
Deposit (UPI or card) success, failed, blocked, on_hold
Pay-out success, failed, on_hold

No webhook is sent while a payment waits for the customer (authenticate) or is being confirmed (pending), and none for a deposit that ends as canceled or expired: the status call shows those. An on_hold payment is later followed by success or failed.

Your endpoint may also receive notices, which carry an event field: the console's test event ("event": "test"), and a notice when a refund or a chargeback is recorded on one of your deposits ("event": "refund" or "event": "chargeback"). Notices move no money; answer them with HTTP 200 and check the deposit with the status call.

The card deposit payload is under Webhook.

The request

Text
POST /webhooks/payee-global HTTP/1.1
Host: shop.example.com
Content-Type: application/json
X-Webhook-Timestamp: 1791021645
X-Webhook-Signature: v1=<64 hex characters>

{"status":"success","merchant_order_id":"ORDER-10045","transaction_id":"TK3V9QX2M1791021600123","amount":"50.00",...}

Verify the signature

Header Value
X-Webhook-Timestamp Unix time in seconds when we signed the delivery
X-Webhook-Signature v1=<hex>, where <hex> is HMAC-SHA256 with your signing key over "<timestamp>.<raw body>", lowercase hex
  1. Read the raw request body before any JSON parsing. Re-serialised JSON will not match.
  2. Reject the delivery if X-Webhook-Timestamp is more than 300 seconds away from your clock.
  3. Compute HMAC-SHA256 with the signing key over the timestamp, a dot and the raw body, as lowercase hex.
  4. Compare it, in constant time, with each v1= value in X-Webhook-Signature. Accept if any one matches.
  5. Only then parse the JSON and act on it. Answer HTTP 401 when the signature fails.

Your signing key is under Developers, then Webhooks: choose Reveal next to Webhook signing key. It is 64 hex characters, and it is derived from your encryption key, so you can also compute it once yourself:

Text
signing_key = HMAC-SHA256(key     = <encryption key, the base64 text as UTF-8 bytes>,
                          message = "payee-global/merchant-webhook-signing/v1")   as hex

The label payee-global/merchant-webhook-signing/v1 is a fixed protocol constant; use it exactly as written.

A receiver

Each receiver verifies the signature on the raw body, answers 401 when it fails, and answers 200 once the message is stored. Replace the log line with your own handling: apply each (transaction_id, status) once.

Examples
// Node 18 or later, as an ES module (.mjs). No dependencies.
import crypto from 'node:crypto';
import http from 'node:http';

const SIGNING_KEY = Buffer.from(process.env.PG_WEBHOOK_SIGNING_KEY || '', 'hex'); // Developers > Webhooks > Reveal

export function verifyWebhook(rawBody, timestamp, signatureHeader, toleranceSeconds = 300) {
  if (!/^\d+$/.test(String(timestamp || ''))) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > toleranceSeconds) return false;
  const expected = Buffer.from(crypto.createHmac('sha256', SIGNING_KEY).update(`${timestamp}.`).update(rawBody).digest('hex'));
  return String(signatureHeader || '').split(',').map((part) => part.trim())
    .filter((part) => part.startsWith('v1='))
    .some((part) => {
      const given = Buffer.from(part.slice(3));
      return given.length === expected.length && crypto.timingSafeEqual(given, expected);
    });
}

http.createServer((req, res) => {
  const chunks = [];
  req.on('data', (chunk) => chunks.push(chunk));
  req.on('end', () => {
    const raw = Buffer.concat(chunks); // the exact bytes we signed: verify before parsing
    if (!verifyWebhook(raw, req.headers['x-webhook-timestamp'], req.headers['x-webhook-signature'])) {
      res.writeHead(401).end();
      return;
    }
    const message = JSON.parse(raw.toString('utf8'));
    if (message.event) {
      // A notice (test, refund, chargeback): no money moved by this message.
      console.log('notice', message.event);
    } else {
      // A payment result. Your code: apply each (transaction_id, status) once.
      console.log('result', message.transaction_id, message.status, message.amount);
    }
    res.writeHead(200).end();
  });
}).listen(Number(process.env.PORT || 8080));

Signature test vector

Using the example encryption key from the test vector above:

Signing key (hex) 24282f2f54fc1aebb51eaab380e0f6fca33ee1dcfd4156f792757d267903e326
Timestamp 1791021600
Raw body {"status":"success","transaction_id":"TK3V9QX2M1791021600123"}
X-Webhook-Signature v1=17158d7c44b07515c37c275b8296ea159a0e5946101f8ba408787761773c912f

The timestamp check in step 2 rejects this old timestamp; test the HMAC step on its own.

Answer with HTTP 200

  • Only HTTP 200 counts as delivered. Any other answer, 201 and 204 included, a redirect, or no answer before we time out, is a failed delivery. Answer within 30 seconds.
  • Answer as soon as the message is verified and stored. Do slow work, such as crediting a wallet or sending email, after you answer or in a queue.
  • Answer 200 to a message you have already processed. Answer 401 only when the signature fails.

Retries

When a payment result is not answered with HTTP 200 we send it again, about once a minute, up to three more times. For a deposit the first retry comes no sooner than 5 minutes after the deposit was created; for a pay-out, no sooner than 15 minutes. Then we stop. Notices are sent once.

To recover anything you missed, use the status call. You can also resend a payment's webhook from the console: open the payment under Payin or Payout and choose Send Webhook.

Duplicates and order

The same result can reach you more than once: after a retry, after a resend from the console, or after a timeout on your side. Record each (transaction_id, status) once and answer 200 to repeats without acting again. Do not assume an order between the webhook and the customer's return to your site.

Trust the signature, not the sending IP address. If your firewall must allow-list our sending address, ask your account manager for it.

The console test event

Developers, then Webhooks, then Send test event posts a signed test message to the webhook address saved in the console.

Webhook event

Console test event

POSTyour webhook address

Signed like every webhook. It is not a payment: check event first and answer HTTP 200 without crediting anyone. Refund and chargeback notices also carry an event field (refund, chargeback).

Payload

Payload
{
  "event": "test",
  "transaction_id": "test_8k2m4q7w1z5x9c3v",
  "status": "success",
  "message": "This is a test event from Payee Global. No money moved.",
  "sent_at": "2026-10-05T09:15:00+00:00"
}

Answer with HTTP 200.

Guides

Status call

POST /v2/upi/status gives a deposit's current status at any time, for UPI and card deposits alike. Prefer webhooks; use the status call:

  • when the customer is back on your site and you have no webhook yet;
  • to reconcile: for example every few minutes, for deposits still authenticate more than 45 minutes after you created them;
  • before you release a large order, if your policy asks for a second check.

Do not poll a deposit more than once every 10 seconds, and stop once it is final.

Examples
curl -sS -X POST 'https://api2.payeeglobal.com/v2/upi/status' \
  -H "Authorization: Bearer $PG_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"merchant_order_id":"ORDER-10045"}'

Fields in the answer

Field Meaning
status The deposit's status: see the status words
merchant_order_id, transaction_id Your reference and ours
amount The deposit amount, a decimal string
message Text for people. Do not parse it; branch on status
upi, utr UPI deposits: the payer's UPI ID and the bank reference, when we have them. null for card deposits
currency, payment_method Present for card deposits
refund_status, refund_date Present once a refund is recorded on the deposit: true and the date
chargebacks_status, chargeback_date Present once a chargeback is recorded on the deposit: true and the date

Ignore fields you do not know: we may add fields.

Deposits

Check a deposit

POSThttps://api2.payeeglobal.com/v2/upi/status

Looks up one of your deposits by transaction_id or merchant_order_id, or a UPI pay-in by the bank's utr. If you send more than one, all must match the same deposit. You only ever see your own deposits. Plain JSON, not encrypted.

Headers

HeaderRequiredValue
AuthorizationYesBearer <API key>
Content-TypeYesapplication/json
X-Api-Status-CodesNostrict gives real HTTP codes on error answers (404 for not found).

Request body

Plain JSON, not encrypted.

Body
{
  "merchant_order_id": "ORDER-10045"
}

Answers

The deposit as it stands, or an error. Read status in the body.

{
  "status": "success",
  "merchant_order_id": "ORDER-10045",
  "transaction_id": "TK3V9QX2M1791021600123",
  "amount": "50.00",
  "message": "Transaction processed successfully.",
  "upi": null,
  "utr": null,
  "currency": "USD",
  "payment_method": "card"
}
Guides

Idempotency and safe retries

Safe retries with Idempotency-Key

Send an Idempotency-Key header on every create-deposit call, and reuse it when you retry the same deposit after a timeout or a network error. Use 1 to 200 printable ASCII characters, unique per deposit; your merchant_order_id is a good choice.

You send You get
The same key with the same deposit The first answer again, byte for byte, with the header Idempotent-Replayed: true. Nothing runs twice
The same key while the first request is still running HTTP 409, {"status": "conflict", "message": "A request with this Idempotency-Key is already in progress."}. Wait a moment and retry
The same key with a different deposit HTTP 422, {"status": "validation_error", "message": "Idempotency-Key was already used with a different request."}
A malformed key HTTP 400, {"status": "validation_error", "message": "Idempotency-Key must be 1-200 printable ASCII characters."}
Any key, when we cannot record it HTTP 503, {"status": "error", "message": "Idempotency-Key could not be processed. Please retry."}. Nothing ran; retry

The key compares the decrypted deposit, so re-encrypting the same fields for a retry is fine.

References are single-use

Independently of the key, a merchant_order_id can be used once per account: a repeat is refused with Duplicate order_id, field must be unique. A new attempt after a failed deposit therefore needs a new merchant_order_id. Pay-outs work the same way with order_id, which is their guard against paying twice.

After a timeout

A timeout does not tell you whether the request ran. Retry a deposit with the same Idempotency-Key: you get the original answer if the first call completed, and nothing is created twice. For a pay-out, check it by order_id with the pay-out status call before anything else, and never resend it with a new order_id.

Guides

Errors and HTTP codes

Always read status in the body. Most answers use HTTP 200 and put the outcome in the body, refusals included.

Error shapes

Callsstatus valuesHTTP
Deposit, status and balance callsvalidation_error, notfound, unauthorized, blocked, failed200 for most; 401 for unauthorized. Strict mode: 400, 404, 401.
Pay-out and pay-out status calls402, 401, 400200 for most; 401 for an IP refusal; 409 for a reused order_id. Strict mode: 400, 404, 422.
Deposit, status and balance calls
{
  "status": "validation_error",
  "message": "Invalid email address"
}
Pay-out and pay-out status calls
{
  "status": 402,
  "message": "Insufficient Amount in your wallet."
}

Deposit, status and balance calls answer {"status": "<word>", "message": "..."}. Pay-out calls put a number in status; when the key or the encrypted body is refused, they answer in the deposit shape, because those checks run before the pay-out is read. The card deposit messages are under Currencies, limits and errors.

HTTP status codes

HTTP When
200 Almost every answer, including refusals
400 A malformed Idempotency-Key; in strict mode, also every validation_error
401 Key, account or IP address refused
404 Strict mode only: the status call found nothing
409 A request with the same Idempotency-Key is still running; a pay-out order_id used before
413 The request body is over 1 MB
422 The Idempotency-Key was used with a different request; in strict mode, a pay-out refused by your account's settings or balance
429 Too many requests: see rate limits. The body is not JSON
500 Strict mode only: a fault on our side. Retry; for a deposit, with the same Idempotency-Key
502, 503, 504 A temporary problem. Retry a deposit with the same Idempotency-Key; check a pay-out by order_id first

Strict mode

Send X-Api-Status-Codes: strict (or X-Api-Version: 2) to get real HTTP codes on error answers: 400 for a validation_error, 404 for not found, 500 for a fault on our side. The body is the same in both modes, so code that branches on status keeps working.

Status words

authenticate, pending, to_be_confirm, success, failed, blocked, on_hold, canceled, expired, settled.

A code the API has no name for is shown as UNKNOWN. Deposit statuses says which are final.

Guides

Rate limits and timeouts

  • The API accepts up to 20 requests per second from each calling IP address, with bursts of up to 40. Above that you get HTTP 429 with a short HTML body. Wait at least one second, then retry; retry a create-deposit call with the same Idempotency-Key.
  • Request bodies are limited to 1 MB.
  • Allow up to 60 seconds for a create call before you treat it as timed out.
  • The status call: no more than once every 10 seconds per payment. Prefer webhooks.
Launch

Testing and launch

Testing with small real amounts

There is no separate test environment. You test on the production API, on your own account, with real cards and small real amounts within your limits. Agree the test window with your account manager first: we watch the first deposits with you. Test deposits are real payments; ask us to refund them.

# Scenario How Expected
1 Successful deposit Create a small deposit and pay with a real card authenticate; then a success webhook that verifies; the status call says success
2 Each currency Repeat in every currency switched on for you As 1, with that currency
3 3-D Secure Pay with a card whose bank asks for a challenge The challenge appears on the secure page; then success
4 Not completed Open the payment page and leave without paying No success webhook, and the status call never says success for it
5 Duplicate reference Re-use a merchant_order_id validation_error, Duplicate order_id, field must be unique.
6 Safe retry Send the same create twice with the same Idempotency-Key The same answer twice; the second has Idempotent-Replayed: true
7 Webhook retry Answer 500 to the first delivery The delivery comes again, no sooner than 5 minutes after the deposit was created
8 Bad signature Change one byte of a stored delivery and replay it to your endpoint Your endpoint answers 401
9 Status call Look up by transaction_id, by merchant_order_id, and an unknown one The deposit; then notfound
10 Test event Developers, then Webhooks, then Send test event Your endpoint answers 200 and credits nothing
11 Limits Create a deposit just below your minimum and one just above your maximum Both refused
12 IP allow-list Call from an address not on your list HTTP 401, not whitelisted
13 Refund Ask us to refund a test deposit The status call shows refund_status: true

Launch checklist

  • The API key and encryption key are on your servers only; nothing in the browser or apps.
  • Your encryption reproduces the test vector.
  • Your server addresses are on the IP allow-list, and every domain that sends customers to deposits is approved in writing.
  • merchant_order_id is unique per deposit, and every create call sends an Idempotency-Key.
  • The customer is sent to authenticate_url unchanged, full page, never in an iframe.
  • Your webhook endpoint is on HTTPS, verifies the signature on the raw body, answers 200 quickly and is idempotent.
  • Deposits are credited only on a verified webhook or a status answer of success, never on the return to response_url.
  • A reconciliation job checks deposits still authenticate after 45 minutes.
  • Your code handles HTTP 429, timeouts and Idempotent-Replayed.
  • transaction_id is stored with every order and appears in your logs.
  • Your team knows how to request refunds and where chargeback notices arrive.
  • Tests 1 to 13 passed, and the first real deposits are watched together with us.

Support

Write to support@payeeglobal.com. About a deposit, include the transaction_id and your merchant_order_id, the amount, the currency and the time in UTC, and the answer you received. Never send keys, encrypted bodies or card numbers to support.

Questions about cards

Not a merchant yet? Talk to us about your countries and methods. Write to support@payeeglobal.com with any integration question. Your keys are in the merchant console: https://app2.payeeglobal.com.