In this guide
Accept your first payment in about 30 minutes
You will create a test deposit, send a customer to the payment page, receive the signed webhook and confirm the payment. Every step has a complete example in curl, PHP, Node.js and Python. It all runs in test mode: no real money moves.
- A test key from the Developers page of your panel.
- A webhook URL any public
httpsaddress on your server. - A language you know curl, PHP, Node.js or Python. Nothing to install.
Prefer to click first? Import the collection and the environment into Postman, paste your test key into api_key and send the requests in order.
Before you start#
You need three things:
- a secret key, from the Developers page of your merchant panel;
- your test webhook secret (
whsec_…), on the same page, to check the test webhooks we send (live webhooks have their own live secret); - a webhook URL: an address on your server that receives them. You send it with every deposit and every withdrawal.
Good to know before the first call:
-
Test and live keys share one site. A test key (
sk_test_…) makes and sees only test deposits, which never move real money. A live key (sk_live_…) makes and sees only live ones. The key decides the mode of the request, and every deposit and webhook says which it is inmode. Start with a test key. -
Keep keys on your server. Never put a key in a web page, a mobile app or a repository. Send it on
every request as
Authorization: Bearer <key>. -
Amounts are whole numbers.
IRRis rials;IRTandTOMare tomans (1 toman = 10 rials). The examples use rials. -
Every deposit and withdrawal carries its own
webhook_url. It is required: any publichttpsURL, at most 2,048 characters. It is the only place we send that deposit's or withdrawal's webhooks, and it never changes. There is no general webhook URL to set in the panel.
The examples read their settings from environment variables, so no key is ever written in a file. Run these lines
once in your terminal, with your own values:
API_URL is the address of the API,
API_KEY is your test key,
WEBHOOK_URL is the address on your server that receives webhooks (the two create examples send it as webhook_url: use your own),
WEBHOOK_SECRET is your test webhook secret, the live one once you go live (only verify-webhook reads it).
export API_URL=https://api.no1paytest.com
export API_KEY=sk_test_…
export WEBHOOK_URL=https://shop.example/webhooks/payments
export WEBHOOK_SECRET=whsec_…
Each example prints the answer on success and exits with 0. On an error it prints
Error <code>: <detail> (request_id <id>) and exits with 1 (see step 10).
1How a payment works#
One payment is six small steps. Your server does three, the customer one, and we two.
Your server Customer's browser Us Each step below says who does it.
-
Your server
Creates the deposit with
POST /v1/deposits: the amount and your order id. Create a deposit → -
Us
Answers with the deposit: its
idand apayment_url. Create a deposit → -
Your server
Sends the customer's browser to
payment_url. Send the customer to the payment page → -
Customer's browser
Pays on the payment page, then comes back to your
redirect_url. Coming back proves nothing. Send the customer to the payment page → -
Us
Sends your server a signed webhook,
deposit.paid. Receive and verify the webhook → -
Your server
Checks the signature, confirms with
GET /v1/deposits/{id}, and only then delivers the order. Confirm with a GET →
2Create a deposit#
POST https://api.no1paytest.com/v1/deposits with a JSON body and an Idempotency-Key header (use your
order id: see step 11). The request below sends method: you choose how the customer
pays. To let the customer choose on our payment page, leave method out
(step 3). Either way you send a webhook_url: it is required.
| Field | Type | Required | What it is |
|---|---|---|---|
amount |
integer | Required | A whole number greater than 0, in the currency you send. |
currency |
string | Required |
The currency of the amount: IRR is rials, IRT and TOM are tomans (1 toman is 10 rials).
One of
IRR,
TOM,
IRT.
|
customer.id |
string | Required | Your own id for the customer. |
customer.level |
string | Optional |
The name of one of your user levels, in any case. A level set for the customer in your panel always wins; otherwise this one is used, otherwise your default level. A name you do not have answers unknown_user_level, even for a customer with a level set in your panel.
|
method |
string | Optional |
How the customer pays. Send it and the customer goes straight to that method's payment page and cannot switch. Leave it out and the customer picks on our method selection page (step 3). When none of your accounts or terminals can take the method right now the answer is method_unavailable, and an amount outside your limits for the method answers amount_outside_method_limits.
One of
account_transfer,
pol_transfer,
card_to_card,
bank_gateway.
|
allowed_methods |
array | Optional |
Only without method: the methods the customer may pick between, one to four of the values below, each once. It only narrows what the method selection page offers: a method you turned off, or one that cannot take this deposit now, is never offered.
One of
account_transfer,
pol_transfer,
card_to_card,
bank_gateway.
|
redirect_url |
string | Required | Where the customer's browser goes after the payment page. Coming back is never proof of payment. Any https URL, at most 2,048 characters. |
webhook_url |
string | Required | The address we send this deposit's webhooks to: the only place they go, and it never changes. Any public https URL, at most 2,048 characters. |
reference |
string | Optional | Your own order id, 1 to 64 printable ASCII characters. It comes back on the deposit and in every webhook. |
{
"amount": 1500000,
"currency": "IRR",
"customer": {
"id": "customer-77"
},
"method": "account_transfer",
"reference": "order-10542",
"redirect_url": "https://shop.example/orders/order-10542/return",
"webhook_url": "https://shop.example/webhooks/payments"
}
bash create-deposit.sh order-10542 1500000 account_transfer
#!/usr/bin/env bash
# Create a deposit: the payment you want your customer to make.
#
# Usage: API_URL=https://api.example.com API_KEY=sk_test_... WEBHOOK_URL=https://your-server.example/webhooks ./create-deposit.sh <order-id> <amount-in-rials> [method]
# Example: ./create-deposit.sh order-10542 1500000 account_transfer (you choose the method)
# ./create-deposit.sh order-10543 1500000 (no method: the customer picks it on our method selection page)
#
# API_URL, API_KEY and WEBHOOK_URL come from the environment, never from this file.
# Use an order id with letters, digits, "-" and "_" only.
set -euo pipefail
usage() {
echo "Usage: API_URL=... API_KEY=... WEBHOOK_URL=... $0 <order-id> <amount-in-rials> [method]" >&2
exit 2
}
[ "$#" -eq 2 ] || [ "$#" -eq 3 ] || usage
[ -n "${API_URL:-}" ] && [ -n "${API_KEY:-}" ] && [ -n "${WEBHOOK_URL:-}" ] || usage
order_id="$1"
amount="$2"
method="${3:-}"
[[ "$order_id" =~ ^[A-Za-z0-9_-]+$ ]] || usage
[[ "$amount" =~ ^[0-9]+$ ]] || usage
[[ "$method" =~ ^[a-z_]*$ ]] || usage
url_pattern='^https://[^"\\[:space:]]+$'
[[ "$WEBHOOK_URL" =~ $url_pattern ]] || usage
# The method is optional. Send it and the customer goes straight to that method's payment page. Leave it out and the
# customer picks the method on our method selection page.
method_member=""
[ -z "$method" ] || method_member=",\"method\":\"$method\""
# The request body. The order id is sent as "reference", so you can match the deposit with your order later.
# webhook_url is where we send this deposit's webhooks: the only place, and it never changes.
body=$(printf '{"amount":%s,"currency":"IRR","customer":{"id":"customer-77"}%s,"reference":"%s","redirect_url":"https://shop.example/orders/%s/return","webhook_url":"%s"}' \
"$amount" "$method_member" "$order_id" "$order_id" "$WEBHOOK_URL")
# The answer goes to a temporary file; curl prints the HTTP status code.
answer=$(mktemp)
trap 'rm -f "$answer"' EXIT
# The Idempotency-Key makes a retry safe: the same key and body return the same deposit, never a second one.
status=$(curl --silent --show-error --output "$answer" --write-out '%{http_code}' \
--request POST "$API_URL/v1/deposits" \
--header "Authorization: Bearer $API_KEY" \
--header "Idempotency-Key: $order_id" \
--header "Content-Type: application/json" \
--data "$body")
# Read one text member of an error answer (an error is JSON with "code", "detail" and "request_id").
field() {
sed -nE 's/.*"'"$1"'":"(([^"\\]|\\.)*)".*/\1/p' "$answer" | head -n 1 | sed -E 's/\\(.)/\1/g'
}
if [[ "$status" == 2* ]]; then
cat "$answer"
echo
else
echo "Error $(field code): $(field detail) (request_id $(field request_id))" >&2
exit 1
fi
php create-deposit.php order-10542 1500000 account_transfer
<?php
// Create a deposit: the payment you want your customer to make.
//
// Usage: API_URL=https://api.example.com API_KEY=sk_test_... WEBHOOK_URL=https://your-server.example/webhooks php create-deposit.php <order-id> <amount-in-rials> [method]
// Example: php create-deposit.php order-10542 1500000 account_transfer (you choose the method)
// php create-deposit.php order-10543 1500000 (no method: the customer picks it on our method selection page)
//
// API_URL, API_KEY and WEBHOOK_URL come from the environment, never from this file. Needs PHP 8.4 with the curl extension.
if (($argc !== 3 && $argc !== 4) || ! getenv('API_URL') || ! getenv('API_KEY') || ! getenv('WEBHOOK_URL')) {
fwrite(STDERR, "Usage: API_URL=... API_KEY=... WEBHOOK_URL=... php create-deposit.php <order-id> <amount-in-rials> [method]\n");
exit(2);
}
[, $orderId, $amount] = $argv;
$method = $argv[3] ?? null;
if (! ctype_digit($amount)) {
fwrite(STDERR, "The amount must be a whole number of rials.\n");
exit(2);
}
// The request body. The order id is sent as "reference", so you can match the deposit with your order later.
// webhook_url is where we send this deposit's webhooks: the only place, and it never changes.
$body = [
'amount' => (int) $amount,
'currency' => 'IRR',
'customer' => ['id' => 'customer-77'],
'reference' => $orderId,
'redirect_url' => 'https://shop.example/orders/'.$orderId.'/return',
'webhook_url' => getenv('WEBHOOK_URL'),
];
// The method is optional. Send it and the customer goes straight to that method's payment page. Leave it out and the
// customer picks the method on our method selection page.
if ($method !== null) {
$body['method'] = $method;
}
// The Idempotency-Key makes a retry safe: the same key and body return the same deposit, never a second one.
$curl = curl_init(rtrim((string) getenv('API_URL'), '/').'/v1/deposits');
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($body),
CURLOPT_HTTPHEADER => [
'Authorization: Bearer '.getenv('API_KEY'),
'Idempotency-Key: '.$orderId,
'Content-Type: application/json',
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
]);
$answer = curl_exec($curl);
if ($answer === false) {
fwrite(STDERR, 'Error connection_failed: '.curl_error($curl)." (request_id none)\n");
exit(1);
}
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
if ($status >= 200 && $status < 300) {
echo $answer, "\n";
exit(0);
}
// An error is JSON with "code", "detail" and "request_id".
$problem = json_decode($answer, true) ?: [];
fwrite(STDERR, sprintf("Error %s: %s (request_id %s)\n", $problem['code'] ?? 'unknown', $problem['detail'] ?? 'no detail', $problem['request_id'] ?? 'none'));
exit(1);
node create-deposit.mjs order-10542 1500000 account_transfer
// Create a deposit: the payment you want your customer to make.
//
// Usage: API_URL=https://api.example.com API_KEY=sk_test_... WEBHOOK_URL=https://your-server.example/webhooks node create-deposit.mjs <order-id> <amount-in-rials> [method]
// Example: node create-deposit.mjs order-10542 1500000 account_transfer (you choose the method)
// node create-deposit.mjs order-10543 1500000 (no method: the customer picks it on our method selection page)
//
// API_URL, API_KEY and WEBHOOK_URL come from the environment, never from this file. Needs Node.js 24 (nothing to install).
const { API_URL, API_KEY, WEBHOOK_URL } = process.env;
const [orderId, amount, method] = process.argv.slice(2);
if (![4, 5].includes(process.argv.length) || !API_URL || !API_KEY || !WEBHOOK_URL || !/^\d+$/.test(amount)) {
console.error('Usage: API_URL=... API_KEY=... WEBHOOK_URL=... node create-deposit.mjs <order-id> <amount-in-rials> [method]');
process.exit(2);
}
// The request body. The order id is sent as "reference", so you can match the deposit with your order later.
// webhook_url is where we send this deposit's webhooks: the only place, and it never changes.
const body = {
amount: Number(amount),
currency: 'IRR',
customer: { id: 'customer-77' },
reference: orderId,
redirect_url: `https://shop.example/orders/${orderId}/return`,
webhook_url: WEBHOOK_URL,
};
// The method is optional. Send it and the customer goes straight to that method's payment page. Leave it out and the
// customer picks the method on our method selection page.
if (method !== undefined) {
body.method = method;
}
// The Idempotency-Key makes a retry safe: the same key and body return the same deposit, never a second one.
let response;
try {
response = await fetch(`${API_URL.replace(/\/$/, '')}/v1/deposits`, {
method: 'POST',
headers: {
Authorization: `Bearer ${API_KEY}`,
'Idempotency-Key': orderId,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(30_000),
});
} catch (error) {
console.error(`Error connection_failed: ${error.cause?.message ?? error.message} (request_id none)`);
process.exit(1);
}
const answer = await response.text();
if (response.ok) {
console.log(answer);
} else {
// An error is JSON with "code", "detail" and "request_id".
let problem = {};
try {
problem = JSON.parse(answer);
} catch {}
console.error(`Error ${problem.code ?? 'unknown'}: ${problem.detail ?? 'no detail'} (request_id ${problem.request_id ?? 'none'})`);
process.exit(1);
}
python3 create-deposit.py order-10542 1500000 account_transfer
# Create a deposit: the payment you want your customer to make.
#
# Usage: API_URL=https://api.example.com API_KEY=sk_test_... WEBHOOK_URL=https://your-server.example/webhooks python3 create-deposit.py <order-id> <amount-in-rials> [method]
# Example: python3 create-deposit.py order-10542 1500000 account_transfer (you choose the method)
# python3 create-deposit.py order-10543 1500000 (no method: the customer picks it on our method selection page)
#
# API_URL, API_KEY and WEBHOOK_URL come from the environment, never from this file. Needs Python 3.9 (nothing to install).
import json
import os
import sys
import urllib.error
import urllib.request
api_url = os.environ.get("API_URL", "")
api_key = os.environ.get("API_KEY", "")
webhook_url = os.environ.get("WEBHOOK_URL", "")
if len(sys.argv) not in (3, 4) or not api_url or not api_key or not webhook_url or not sys.argv[2].isdigit():
print("Usage: API_URL=... API_KEY=... WEBHOOK_URL=... python3 create-deposit.py <order-id> <amount-in-rials> [method]", file=sys.stderr)
sys.exit(2)
order_id = sys.argv[1]
amount = int(sys.argv[2])
# The request body. The order id is sent as "reference", so you can match the deposit with your order later.
# webhook_url is where we send this deposit's webhooks: the only place, and it never changes.
body = {
"amount": amount,
"currency": "IRR",
"customer": {"id": "customer-77"},
"reference": order_id,
"redirect_url": "https://shop.example/orders/" + order_id + "/return",
"webhook_url": webhook_url,
}
# The method is optional. Send it and the customer goes straight to that method's payment page. Leave it out and the
# customer picks the method on our method selection page.
if len(sys.argv) == 4:
body["method"] = sys.argv[3]
# The Idempotency-Key makes a retry safe: the same key and body return the same deposit, never a second one.
request = urllib.request.Request(
api_url.rstrip("/") + "/v1/deposits",
data=json.dumps(body).encode("utf-8"),
method="POST",
headers={
"Authorization": "Bearer " + api_key,
"Idempotency-Key": order_id,
"Content-Type": "application/json",
},
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
print(response.read().decode("utf-8"))
except urllib.error.HTTPError as error:
# An error is JSON with "code", "detail" and "request_id".
try:
problem = json.loads(error.read().decode("utf-8"))
except ValueError:
problem = {}
print(
"Error %s: %s (request_id %s)"
% (problem.get("code", "unknown"), problem.get("detail", "no detail"), problem.get("request_id", "none")),
file=sys.stderr,
)
sys.exit(1)
except (urllib.error.URLError, OSError) as error:
print("Error connection_failed: %s (request_id none)" % error, file=sys.stderr)
sys.exit(1)
The answer is 201 with the deposit. Keep its id (dep_…) with your order. A new
deposit has "status": "pending" and "version": 1. The answer also holds
payment_url, the link to send the customer to (use it exactly as it comes: never build or change one); it is returned
when you create the deposit (and again if you repeat the same request), not when you get it later, so store it or
use it at once. This is the answer to the request the examples send:
201 The deposit, with its payment_url
{
"id": "dep_01k6c3s0v1m2n3p4q5r6s7t8v9",
"object": "deposit",
"mode": "test",
"status": "pending",
"method": "account_transfer",
"amount": 1500000,
"currency": "IRR",
"amount_rials": 1500000,
"fee_rials": null,
"reference": "order-10542",
"customer": {
"id": "customer-77",
"level": "default"
},
"payer": {
"card_number": null,
"account": null,
"bank": null
},
"redirect_url": "https://shop.example/orders/order-10542/return",
"webhook_url": "https://shop.example/webhooks/payments",
"allowed_methods": null,
"failure_code": null,
"failure_message": null,
"version": 1,
"created_at": "2026-10-03T09:30:00Z",
"updated_at": "2026-10-03T09:30:00Z",
"expires_at": "2026-10-03T10:00:00Z",
"paid_at": null,
"payment_url": "https://pay.no1paytest.com/checkout/Zm9yLXRoZS1ndWlkZS1vbg"
}
422 The error a deposit without an amount gets
Every error looks like this. More in Errors (step 10).
{
"type": "https://docs.no1paytest.com/errors/validation_failed",
"status": 422,
"code": "validation_failed",
"title": "The request is not valid",
"detail": "Fix the fields listed in \"errors\" and send the request again.",
"errors": {
"amount": [
"Fill in amount."
]
},
"request_id": "req_01k6c0z7x4m2p9q8r5s3t1v0w2"
}
Methods and limits. On the Limits page of your panel you turn methods on or off and give every method
its own smallest and largest deposit. A deposit with a method you turned off is refused with
method_unavailable, and so is one that no account or terminal of yours can take right now. An amount
outside your limits for the method is refused with amount_outside_method_limits, and the
detail names the amount and your limits:
422 An amount outside your limits for its method
{
"type": "https://docs.no1paytest.com/errors/amount_outside_method_limits",
"status": 422,
"code": "amount_outside_method_limits",
"title": "The amount is outside your limits for this method",
"detail": "A account_transfer deposit must be between 10000 and 1000000 rials (your limits for this method).",
"request_id": "req_01k6c0z7x4m2p9q8r5s3t1v0w2"
}
The commands of the next steps read your deposit's id from DEPOSIT_ID. Put the id of the answer you got there:
export DEPOSIT_ID=dep_…
You should get 201, "status": "pending" and a payment_url. If you do not:
- A wrong or missing key answers
authentication_failed. SendAuthorization: Bearer <key>with a test key. - A missing field, a missing
webhook_urlor a missingIdempotency-Keyanswersvalidation_failed;errorsnames each field. - A live key while live mode is off answers
live_mode_disabled. Use your test key.
3Choose who picks the method#
A deposit is paid with one of four methods. Which one is the question of this step:
| Method | How the customer pays |
|---|---|
account_transfer |
A bank account transfer: the customer transfers to a bank account of yours. |
pol_transfer |
A pol transfer: the customer sends an instant pol transfer to your account's Sheba. |
card_to_card |
A card-to-card transfer: the customer pays by card-to-card. |
bank_gateway |
An Iranian bank payment gateway: the customer pays on the bank's own page. |
You decide who picks by what you send. With method, you choose and the customer goes
straight to that method's payment page. Without it, the customer lands on our method selection page
and chooses. Some merchants already ask the customer how to pay in their own shop and send the answer; others would
rather skip that and let our page do it. Both are fine, and both end the same way.
| Question | You send method | You leave method out |
|---|---|---|
| What the customer sees first | That method's payment page. | Our method selection page, with only the methods that work now for this customer and amount. |
| The deposit right after you create it | pending, with the method you sent. |
waiting_for_method, with method null. |
| Can the customer pick another method | No, never. There is no selection page for this deposit. | Yes, until money may move. |
| What you get when the customer picks | Nothing to do: the method is already set. | No webhook. The deposit becomes pending with method set (version goes up by 1); GET shows it. |
You choose the method#
This is the request of step 2: it sends method, here
"method": "account_transfer". The customer goes straight to that method's payment
page and cannot switch. If nothing of yours can take the method right now, the create call itself is refused with
method_unavailable, so you can offer something else in your shop.
The customer chooses#
Leave method out. Everything else is the same: the same fields, the same webhook_url, the same
examples (give the example program no third argument; use another order id, since the order id is also the
Idempotency-Key).
{
"amount": 1500000,
"currency": "IRR",
"customer": {
"id": "customer-77"
},
"reference": "order-10543",
"redirect_url": "https://shop.example/orders/order-10543/return",
"webhook_url": "https://shop.example/webhooks/payments"
}
The command, here with the curl example (the PHP, Node.js and Python programs take the same arguments):
bash create-deposit.sh order-10543 1500000
The answer is 201 as before, with "status": "waiting_for_method" and
"method": null, and a payment_url. Send the customer there: it
opens our method selection page instead of a payment page.
201 A deposit waiting for the customer's method
{
"id": "dep_01k6c3s0v1m2n3p4q5r6s7t8v9",
"object": "deposit",
"mode": "test",
"status": "waiting_for_method",
"method": null,
"amount": 1500000,
"currency": "IRR",
"amount_rials": 1500000,
"fee_rials": null,
"reference": "order-10543",
"customer": {
"id": "customer-77",
"level": "default"
},
"payer": {
"card_number": null,
"account": null,
"bank": null
},
"redirect_url": "https://shop.example/orders/order-10543/return",
"webhook_url": "https://shop.example/webhooks/payments",
"allowed_methods": null,
"failure_code": null,
"failure_message": null,
"version": 1,
"created_at": "2026-10-03T09:30:00Z",
"updated_at": "2026-10-03T09:30:00Z",
"expires_at": "2026-10-03T10:30:00Z",
"paid_at": null,
"payment_url": "https://pay.no1paytest.com/checkout/Zm9yLXRoZS1ndWlkZS1vbg"
}
What the customer sees#
- Only the methods that work now. The selection page lists the methods that are turned on for you, that the customer is not blocked from, whose limits the amount is inside, and that one of your accounts, terminals or cards can take right now. A method that fails any of these is not shown.
-
Nothing available. If no method works when you create the deposit, the call is refused with
method_unavailable, and no deposit is made. -
The customer picks. The deposit becomes
pendingwith the pickedmethod, and the customer sees that method's payment page. A method that stopped working in the meantime is refused on the page, which shows the list again. The same happens when the picked method cannot start once the customer goes on (no gateway terminal or card payment is free any more): the deposit goes back towaiting_for_methodwithmethodnull, neverfailed, and no webhook is sent. -
"Pay another way". Until money may move, the customer can go back to the selection page and pick
again: the deposit is then
waiting_for_methodwithmethodnull again. Money may move once the customer has sent the transfer details, the card form or started a gateway payment: from then on there is no way back, and a deposit never changes method after that. -
Never picked. A selection page nobody uses expires like any deposit:
deposit.expired, withmethodnull in its body.
You can narrow the choice with allowed_methods, used only without method: a JSON list of one to
four method values, each once, for example
account_transfer and
card_to_card
(the customer then chooses between those two). It only
narrows what the page offers; it cannot offer a method you turned off or that cannot take the deposit now. Sent together
with method it is refused with validation_failed.
What your code sees. No webhook is sent when the customer picks a method or goes back: nothing has
ended yet. Read the picked method from the deposit when you need it: it is in every later webhook and in
GET. While the deposit is waiting_for_method, method is null: that is not an error.
expires_at is set again when the customer picks: the picked method's own time, never more than
120 minutes after the deposit was made. Read it from
GET or the webhook, not only from the create answer.
Everything else of step 4 to step 6 is the same.
In test mode the customer picks on the test payment page too, with no accounts needed. To end such a deposit from your
server, the simulator picks the method first, as the customer would: send method with
outcome (step 7):
{
"outcome": "paid",
"method": "pol_transfer"
}
4Send the customer to the payment page#
Redirect the customer's browser to payment_url: a link on our payment page's address, ending in
/checkout/ and a code of 22 characters. It works only for this
deposit and stops working when the deposit is final. If you left method out, it opens our method selection
page (step 3); otherwise the payment page of the method you sent. After the payment the customer
returns to your redirect_url.
With a test key, open payment_url yourself: the test payment page has a paid button instead of a real
payment. Pressing it (or the simulate call of step 7) sends the webhook of
step 5.
The customer coming back to your redirect_url is never proof of payment. It only
tells you the customer is back. Wait for the webhook, then confirm (steps 5 and
6). Deliver an order only when the deposit's status is paid.
5Receive and verify the webhook#
When a deposit ends or changes we POST a JSON event to the deposit's own webhook_url: the address you sent
when you created it. It is the only place we send that deposit's webhooks, and it never changes: to receive the
next deposit's webhooks somewhere else, send that address when you create it. In test mode you get one by pressing
the paid button on a test deposit's payment page (step 7). The body looks like this, with
the deposit in data:
{"id":"whd_01k6c3s0v1m2n3p4q5r6s7t8v9","data":{"id":"dep_01k6c3s0v1m2n3p4q5r6s7t8v9","mode":"test","amount":1500000,"method":"account_transfer","object":"deposit","status":"paid","paid_at":"2026-10-03T09:30:12Z","version":2,"currency":"IRR","customer":{"id":"customer-77","level":"default"},"fee_rials":22500,"reference":"order-10542","created_at":"2026-10-03T09:30:00Z","expires_at":"2026-10-03T10:00:00Z","updated_at":"2026-10-03T09:30:12Z","webhook_url":"https://shop.example/webhooks/payments","amount_rials":1500000,"failure_code":null,"redirect_url":"https://shop.example/orders/order-10542/return","allowed_methods":null,"failure_message":null},"mode":"test","type":"deposit.paid","created_at":"2026-10-03T09:30:12Z"}
(data is the deposit object without payer: no card number, account number or Sheba ever travels in a webhook. This is the exact text we send, on one line: the signature is made
over these bytes. Its members come in the order we send them, which is not the order of an API answer: parse the
JSON, never rely on the order.) Each request carries these headers:
Webhook-Id: whd_01k6c3s0v1m2n3p4q5r6s7t8v9
Webhook-Timestamp: 1791019812
Webhook-Signature: v1,pWO5zzI19dESgKJVkhz0RflSn9eU+772kmTdfzahCM0=
Webhook-Event: deposit.paid
The same body, indented to read
{
"id": "whd_01k6c3s0v1m2n3p4q5r6s7t8v9",
"data": {
"id": "dep_01k6c3s0v1m2n3p4q5r6s7t8v9",
"mode": "test",
"amount": 1500000,
"method": "account_transfer",
"object": "deposit",
"status": "paid",
"paid_at": "2026-10-03T09:30:12Z",
"version": 2,
"currency": "IRR",
"customer": {
"id": "customer-77",
"level": "default"
},
"fee_rials": 22500,
"reference": "order-10542",
"created_at": "2026-10-03T09:30:00Z",
"expires_at": "2026-10-03T10:00:00Z",
"updated_at": "2026-10-03T09:30:12Z",
"webhook_url": "https://shop.example/webhooks/payments",
"amount_rials": 1500000,
"failure_code": null,
"redirect_url": "https://shop.example/orders/order-10542/return",
"allowed_methods": null,
"failure_message": null
},
"mode": "test",
"type": "deposit.paid",
"created_at": "2026-10-03T09:30:12Z"
}
This signature is real: it was made with the made-up secret whsec_bWFkZSB1cCBzZWNyZXQgb2YgdGhlIGd1aWRlIG9ubHk= over the body above,
exactly as shown. The Run it command of each example below writes that body to body.json
byte for byte and checks it with that secret. It prints invalid: the sample was signed at
2026-10-03T09:30:12Z, more than 5 minutes ago, and every example refuses an old
timestamp, as your server must. To see valid, check a webhook you really receive: create a test
deposit and press its paid button (step 7).
Webhooks follow the Standard Webhooks format, so a ready library for your language can check them. To check one yourself:
- Take the raw request body exactly as received, before any JSON parsing.
-
Build the string
<Webhook-Id>.<Webhook-Timestamp>.<raw body>. -
Compute HMAC-SHA256 of it with the webhook secret of its
mode(test or live) as the key: the part afterwhsec_, base64-decoded. Base64-encode the result and putv1,in front. -
Compare it, in constant time, with each value in
Webhook-Signature(they are separated by spaces). One must match. - Refuse the event when its timestamp is more than 5 minutes from your clock.
printf '%s' '{"id":"whd_01k6c3s0v1m2n3p4q5r6s7t8v9","data":{"id":"dep_01k6c3s0v1m2n3p4q5r6s7t8v9","mode":"test","amount":1500000,"method":"account_transfer","object":"deposit","status":"paid","paid_at":"2026-10-03T09:30:12Z","version":2,"currency":"IRR","customer":{"id":"customer-77","level":"default"},"fee_rials":22500,"reference":"order-10542","created_at":"2026-10-03T09:30:00Z","expires_at":"2026-10-03T10:00:00Z","updated_at":"2026-10-03T09:30:12Z","webhook_url":"https://shop.example/webhooks/payments","amount_rials":1500000,"failure_code":null,"redirect_url":"https://shop.example/orders/order-10542/return","allowed_methods":null,"failure_message":null},"mode":"test","type":"deposit.paid","created_at":"2026-10-03T09:30:12Z"}' > body.json
WEBHOOK_ID=whd_01k6c3s0v1m2n3p4q5r6s7t8v9 WEBHOOK_TIMESTAMP=1791019812 WEBHOOK_SIGNATURE=v1,pWO5zzI19dESgKJVkhz0RflSn9eU+772kmTdfzahCM0= WEBHOOK_SECRET=whsec_bWFkZSB1cCBzZWNyZXQgb2YgdGhlIGd1aWRlIG9ubHk= bash verify-webhook.sh < body.json
#!/usr/bin/env bash
# Check that a webhook really comes from us (Standard Webhooks signature).
#
# Usage: WEBHOOK_ID=... WEBHOOK_TIMESTAMP=... WEBHOOK_SIGNATURE=... WEBHOOK_SECRET=whsec_... ./verify-webhook.sh < body.json
# The values are the Webhook-Id, Webhook-Timestamp and Webhook-Signature headers of the request and your webhook
# secret. The body is the raw request body, exactly as received (before any JSON parsing). Needs openssl.
# Prints "valid" (exit 0) or "invalid" (exit 1).
#
# Note: bash cannot compare text in constant time. In your real server use your language's constant-time compare
# (see the PHP, Node.js and Python versions of this example).
set -euo pipefail
# verify <secret> <webhook-id> <webhook-timestamp> <signature-header> <file-with-the-raw-body>
verify() {
local secret="$1" id="$2" timestamp="$3" signatures="$4" body_file="$5"
# Refuse a timestamp that is not a number or is more than 5 minutes from your clock (replay protection).
[[ "$timestamp" =~ ^[0-9]+$ ]] || return 1
local now difference
now=$(date +%s)
difference=$((now - timestamp))
if [ "$difference" -lt 0 ]; then difference=$((-difference)); fi
[ "$difference" -le 300 ] || return 1
# The key is the secret after "whsec_", base64-decoded (written as hex for openssl).
local key_hex
key_hex=$(printf '%s' "${secret#whsec_}" | openssl base64 -d -A | od -An -vtx1 | tr -d ' \n')
[ -n "$key_hex" ] || return 1
# HMAC-SHA256 over "<id>.<timestamp>.<raw body>", base64-encoded, with "v1," in front.
local expected
expected="v1,$({
printf '%s.%s.' "$id" "$timestamp"
cat "$body_file"
} | openssl dgst -sha256 -mac HMAC -macopt "hexkey:$key_hex" -binary | openssl base64 -A)"
# The header may hold several signatures separated by spaces (while a secret is being changed): one must match.
local signature
for signature in $signatures; do
if [ "$signature" = "$expected" ]; then
return 0
fi
done
return 1
}
body_file=$(mktemp)
trap 'rm -f "$body_file"' EXIT
cat > "$body_file"
if verify "${WEBHOOK_SECRET:-}" "${WEBHOOK_ID:-}" "${WEBHOOK_TIMESTAMP:-}" "${WEBHOOK_SIGNATURE:-}" "$body_file"; then
echo valid
else
echo invalid
exit 1
fi
printf '%s' '{"id":"whd_01k6c3s0v1m2n3p4q5r6s7t8v9","data":{"id":"dep_01k6c3s0v1m2n3p4q5r6s7t8v9","mode":"test","amount":1500000,"method":"account_transfer","object":"deposit","status":"paid","paid_at":"2026-10-03T09:30:12Z","version":2,"currency":"IRR","customer":{"id":"customer-77","level":"default"},"fee_rials":22500,"reference":"order-10542","created_at":"2026-10-03T09:30:00Z","expires_at":"2026-10-03T10:00:00Z","updated_at":"2026-10-03T09:30:12Z","webhook_url":"https://shop.example/webhooks/payments","amount_rials":1500000,"failure_code":null,"redirect_url":"https://shop.example/orders/order-10542/return","allowed_methods":null,"failure_message":null},"mode":"test","type":"deposit.paid","created_at":"2026-10-03T09:30:12Z"}' > body.json
WEBHOOK_ID=whd_01k6c3s0v1m2n3p4q5r6s7t8v9 WEBHOOK_TIMESTAMP=1791019812 WEBHOOK_SIGNATURE=v1,pWO5zzI19dESgKJVkhz0RflSn9eU+772kmTdfzahCM0= WEBHOOK_SECRET=whsec_bWFkZSB1cCBzZWNyZXQgb2YgdGhlIGd1aWRlIG9ubHk= php verify-webhook.php < body.json
<?php
// Check that a webhook really comes from us (Standard Webhooks signature).
//
// Usage: WEBHOOK_ID=... WEBHOOK_TIMESTAMP=... WEBHOOK_SIGNATURE=... WEBHOOK_SECRET=whsec_... php verify-webhook.php < body.json
// The values are the Webhook-Id, Webhook-Timestamp and Webhook-Signature headers of the request and your webhook
// secret. The body is the raw request body, exactly as received (before any JSON parsing).
// Prints "valid" (exit 0) or "invalid" (exit 1).
/**
* @param array<string, string> $headers the request headers (any capitalisation)
*/
function verify(string $secret, array $headers, string $body): bool
{
$headers = array_change_key_case($headers, CASE_LOWER);
$id = $headers['webhook-id'] ?? '';
$timestamp = $headers['webhook-timestamp'] ?? '';
$signatures = $headers['webhook-signature'] ?? '';
// Refuse a timestamp that is not a number or is more than 5 minutes from your clock (replay protection).
if (! ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) {
return false;
}
// The key is the secret after "whsec_", base64-decoded.
$key = base64_decode(substr($secret, strlen('whsec_')), true);
if ($key === false || $key === '') {
return false;
}
// HMAC-SHA256 over "<id>.<timestamp>.<raw body>", base64-encoded, with "v1," in front.
$expected = 'v1,'.base64_encode(hash_hmac('sha256', $id.'.'.$timestamp.'.'.$body, $key, true));
// The header may hold several signatures separated by spaces (while a secret is being changed): one must match.
foreach (explode(' ', $signatures) as $signature) {
if (hash_equals($expected, $signature)) {
return true;
}
}
return false;
}
// Run as a program (not when another file includes this one): read the body from stdin, the rest from the environment.
if (PHP_SAPI === 'cli' && isset($_SERVER['SCRIPT_FILENAME']) && realpath($_SERVER['SCRIPT_FILENAME']) === __FILE__) {
$valid = verify(
(string) getenv('WEBHOOK_SECRET'),
[
'Webhook-Id' => (string) getenv('WEBHOOK_ID'),
'Webhook-Timestamp' => (string) getenv('WEBHOOK_TIMESTAMP'),
'Webhook-Signature' => (string) getenv('WEBHOOK_SIGNATURE'),
],
(string) stream_get_contents(STDIN),
);
echo $valid ? "valid\n" : "invalid\n";
exit($valid ? 0 : 1);
}
printf '%s' '{"id":"whd_01k6c3s0v1m2n3p4q5r6s7t8v9","data":{"id":"dep_01k6c3s0v1m2n3p4q5r6s7t8v9","mode":"test","amount":1500000,"method":"account_transfer","object":"deposit","status":"paid","paid_at":"2026-10-03T09:30:12Z","version":2,"currency":"IRR","customer":{"id":"customer-77","level":"default"},"fee_rials":22500,"reference":"order-10542","created_at":"2026-10-03T09:30:00Z","expires_at":"2026-10-03T10:00:00Z","updated_at":"2026-10-03T09:30:12Z","webhook_url":"https://shop.example/webhooks/payments","amount_rials":1500000,"failure_code":null,"redirect_url":"https://shop.example/orders/order-10542/return","allowed_methods":null,"failure_message":null},"mode":"test","type":"deposit.paid","created_at":"2026-10-03T09:30:12Z"}' > body.json
WEBHOOK_ID=whd_01k6c3s0v1m2n3p4q5r6s7t8v9 WEBHOOK_TIMESTAMP=1791019812 WEBHOOK_SIGNATURE=v1,pWO5zzI19dESgKJVkhz0RflSn9eU+772kmTdfzahCM0= WEBHOOK_SECRET=whsec_bWFkZSB1cCBzZWNyZXQgb2YgdGhlIGd1aWRlIG9ubHk= node verify-webhook.mjs < body.json
// Check that a webhook really comes from us (Standard Webhooks signature).
//
// Usage: WEBHOOK_ID=... WEBHOOK_TIMESTAMP=... WEBHOOK_SIGNATURE=... WEBHOOK_SECRET=whsec_... node verify-webhook.mjs < body.json
// The values are the Webhook-Id, Webhook-Timestamp and Webhook-Signature headers of the request and your webhook
// secret. The body is the raw request body, exactly as received (before any JSON parsing).
// Prints "valid" (exit 0) or "invalid" (exit 1). Needs Node.js 24 (nothing to install).
import { createHmac, timingSafeEqual } from 'node:crypto';
import { pathToFileURL } from 'node:url';
// headers: the request headers as an object with lower-case names (what Node's request.headers gives you).
// body: the raw request body, a string or a Buffer.
export function verify(secret, headers, body) {
const id = headers['webhook-id'] ?? '';
const timestamp = headers['webhook-timestamp'] ?? '';
const signatures = headers['webhook-signature'] ?? '';
// Refuse a timestamp that is not a number or is more than 5 minutes from your clock (replay protection).
if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return false;
}
// The key is the secret after "whsec_", base64-decoded.
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
if (key.length === 0) {
return false;
}
// HMAC-SHA256 over "<id>.<timestamp>.<raw body>", base64-encoded, with "v1," in front.
const signed = Buffer.concat([Buffer.from(`${id}.${timestamp}.`), Buffer.from(body)]);
const expected = Buffer.from(`v1,${createHmac('sha256', key).update(signed).digest('base64')}`);
// The header may hold several signatures separated by spaces (while a secret is being changed): one must match.
return signatures.split(' ').some((signature) => {
const given = Buffer.from(signature);
return given.length === expected.length && timingSafeEqual(given, expected);
});
}
// Run as a program (not when another file imports this one): read the body from stdin, the rest from the environment.
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const chunks = [];
for await (const chunk of process.stdin) {
chunks.push(chunk);
}
const valid = verify(
process.env.WEBHOOK_SECRET ?? '',
{
'webhook-id': process.env.WEBHOOK_ID,
'webhook-timestamp': process.env.WEBHOOK_TIMESTAMP,
'webhook-signature': process.env.WEBHOOK_SIGNATURE,
},
Buffer.concat(chunks),
);
console.log(valid ? 'valid' : 'invalid');
process.exit(valid ? 0 : 1);
}
printf '%s' '{"id":"whd_01k6c3s0v1m2n3p4q5r6s7t8v9","data":{"id":"dep_01k6c3s0v1m2n3p4q5r6s7t8v9","mode":"test","amount":1500000,"method":"account_transfer","object":"deposit","status":"paid","paid_at":"2026-10-03T09:30:12Z","version":2,"currency":"IRR","customer":{"id":"customer-77","level":"default"},"fee_rials":22500,"reference":"order-10542","created_at":"2026-10-03T09:30:00Z","expires_at":"2026-10-03T10:00:00Z","updated_at":"2026-10-03T09:30:12Z","webhook_url":"https://shop.example/webhooks/payments","amount_rials":1500000,"failure_code":null,"redirect_url":"https://shop.example/orders/order-10542/return","allowed_methods":null,"failure_message":null},"mode":"test","type":"deposit.paid","created_at":"2026-10-03T09:30:12Z"}' > body.json
WEBHOOK_ID=whd_01k6c3s0v1m2n3p4q5r6s7t8v9 WEBHOOK_TIMESTAMP=1791019812 WEBHOOK_SIGNATURE=v1,pWO5zzI19dESgKJVkhz0RflSn9eU+772kmTdfzahCM0= WEBHOOK_SECRET=whsec_bWFkZSB1cCBzZWNyZXQgb2YgdGhlIGd1aWRlIG9ubHk= python3 verify-webhook.py < body.json
# Check that a webhook really comes from us (Standard Webhooks signature).
#
# Usage: WEBHOOK_ID=... WEBHOOK_TIMESTAMP=... WEBHOOK_SIGNATURE=... WEBHOOK_SECRET=whsec_... python3 verify-webhook.py < body.json
# The values are the Webhook-Id, Webhook-Timestamp and Webhook-Signature headers of the request and your webhook
# secret. The body is the raw request body, exactly as received (before any JSON parsing).
# Prints "valid" (exit 0) or "invalid" (exit 1). Needs Python 3.9 (nothing to install).
import base64
import binascii
import hashlib
import hmac
import os
import sys
import time
def verify(secret, headers, body):
"""headers: the request headers as a dict with lower-case names. body: the raw request body, as bytes."""
webhook_id = headers.get("webhook-id", "")
timestamp = headers.get("webhook-timestamp", "")
signatures = headers.get("webhook-signature", "")
# Refuse a timestamp that is not a number or is more than 5 minutes from your clock (replay protection).
if not (timestamp.isascii() and timestamp.isdigit()) or abs(time.time() - int(timestamp)) > 300:
return False
# The key is the secret after "whsec_", base64-decoded.
encoded = secret[len("whsec_"):] if secret.startswith("whsec_") else secret
try:
key = base64.b64decode(encoded, validate=True)
except (binascii.Error, ValueError):
return False
if not key:
return False
# HMAC-SHA256 over "<id>.<timestamp>.<raw body>", base64-encoded, with "v1," in front.
signed = (webhook_id + "." + timestamp + ".").encode("utf-8") + body
expected = "v1," + base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode("ascii")
# The header may hold several signatures separated by spaces (while a secret is being changed): one must match.
return any(hmac.compare_digest(expected.encode("utf-8"), signature.encode("utf-8")) for signature in signatures.split(" "))
# Run as a program (not when another file imports this one): read the body from stdin, the rest from the environment.
if __name__ == "__main__":
valid = verify(
os.environ.get("WEBHOOK_SECRET", ""),
{
"webhook-id": os.environ.get("WEBHOOK_ID", ""),
"webhook-timestamp": os.environ.get("WEBHOOK_TIMESTAMP", ""),
"webhook-signature": os.environ.get("WEBHOOK_SIGNATURE", ""),
},
sys.stdin.buffer.read(),
)
print("valid" if valid else "invalid")
sys.exit(0 if valid else 1)
Handle each webhook once#
Webhooks can arrive more than once. A "Send again", an answer of yours that got lost on the way, a
delivery we had to queue again or, with Automatic retries on, a retry can deliver the same event again. Keep the
Webhook-Id of every event you handled and skip a repeat, or skip an event whose
data.version is not higher than the one you stored for that deposit or withdrawal.
This example keeps the ids in a file and says new the first time and duplicate after that:
run the command twice. In your own server keep the ids in your database under a unique key.
WEBHOOK_ID=whd_01k6c3s0v1m2n3p4q5r6s7t8v9 SEEN_FILE=seen-webhooks.txt bash webhook-once.sh
#!/usr/bin/env bash
# Handle each webhook once: keep the id of every webhook you handled and skip a repeat.
#
# Usage: WEBHOOK_ID=whd_... SEEN_FILE=seen-webhooks.txt ./webhook-once.sh
# WEBHOOK_ID is the Webhook-Id header of the request: the same on every delivery of one event (a "Send again", a lost
# answer or a retry sends it again). SEEN_FILE is a text file with one handled id per line; it is created when it is
# missing. Prints "new" the first time an id is seen (and remembers it) and "duplicate" after that. Both exit 0.
# No network call.
#
# Note: a file is enough to see the idea. In your real server keep the ids in your database under a UNIQUE key, so two
# deliveries that arrive at the same moment can never both be new.
set -euo pipefail
usage() {
echo "Usage: WEBHOOK_ID=... SEEN_FILE=... $0" >&2
exit 2
}
[ "$#" -eq 0 ] || usage
[ -n "${WEBHOOK_ID:-}" ] && [ -n "${SEEN_FILE:-}" ] || usage
[[ "$WEBHOOK_ID" =~ ^[^[:space:][:cntrl:]]+$ ]] || usage
touch "$SEEN_FILE"
# Whole-line, exact match: an id that only contains another is not a repeat.
if grep -Fxq -- "$WEBHOOK_ID" "$SEEN_FILE"; then
echo duplicate
else
printf '%s\n' "$WEBHOOK_ID" >> "$SEEN_FILE"
echo new
fi
WEBHOOK_ID=whd_01k6c3s0v1m2n3p4q5r6s7t8v9 SEEN_FILE=seen-webhooks.txt php webhook-once.php
<?php
// Handle each webhook once: keep the id of every webhook you handled and skip a repeat.
//
// Usage: WEBHOOK_ID=whd_... SEEN_FILE=seen-webhooks.txt php webhook-once.php
// WEBHOOK_ID is the Webhook-Id header of the request: the same on every delivery of one event (a "Send again", a lost
// answer or a retry sends it again). SEEN_FILE is a text file with one handled id per line; it is created when it is
// missing. Prints "new" the first time an id is seen (and remembers it) and "duplicate" after that. Both exit 0.
// No network call.
//
// Note: a file is enough to see the idea. In your real server keep the ids in your database under a UNIQUE key, so two
// deliveries that arrive at the same moment can never both be new.
/**
* True the first time $webhookId is seen (it is written to $seenFile), false for every repeat.
*/
function isNew(string $seenFile, string $webhookId): bool
{
$seen = is_file($seenFile) ? (file($seenFile, FILE_IGNORE_NEW_LINES) ?: []) : [];
if (in_array($webhookId, $seen, true)) {
return false;
}
file_put_contents($seenFile, $webhookId."\n", FILE_APPEND);
return true;
}
// Run as a program (not when another file includes this one): the id and the file come from the environment.
if (PHP_SAPI === 'cli' && isset($_SERVER['SCRIPT_FILENAME']) && realpath($_SERVER['SCRIPT_FILENAME']) === __FILE__) {
$webhookId = (string) getenv('WEBHOOK_ID');
$seenFile = (string) getenv('SEEN_FILE');
if ($argc !== 1 || $webhookId === '' || $seenFile === '' || preg_match('/[\s\x00-\x1f]/', $webhookId) === 1) {
fwrite(STDERR, "Usage: WEBHOOK_ID=... SEEN_FILE=... php webhook-once.php\n");
exit(2);
}
echo isNew($seenFile, $webhookId) ? "new\n" : "duplicate\n";
}
WEBHOOK_ID=whd_01k6c3s0v1m2n3p4q5r6s7t8v9 SEEN_FILE=seen-webhooks.txt node webhook-once.mjs
// Handle each webhook once: keep the id of every webhook you handled and skip a repeat.
//
// Usage: WEBHOOK_ID=whd_... SEEN_FILE=seen-webhooks.txt node webhook-once.mjs
// WEBHOOK_ID is the Webhook-Id header of the request: the same on every delivery of one event (a "Send again", a lost
// answer or a retry sends it again). SEEN_FILE is a text file with one handled id per line; it is created when it is
// missing. Prints "new" the first time an id is seen (and remembers it) and "duplicate" after that. Both exit 0.
// No network call. Needs Node.js 24 (nothing to install).
//
// Note: a file is enough to see the idea. In your real server keep the ids in your database under a UNIQUE key, so two
// deliveries that arrive at the same moment can never both be new.
import { appendFileSync, existsSync, readFileSync } from 'node:fs';
import { pathToFileURL } from 'node:url';
// True the first time webhookId is seen (it is written to seenFile), false for every repeat.
export function isNew(seenFile, webhookId) {
const seen = existsSync(seenFile) ? readFileSync(seenFile, 'utf8').split('\n') : [];
if (seen.includes(webhookId)) {
return false;
}
appendFileSync(seenFile, `${webhookId}\n`);
return true;
}
// Run as a program (not when another file imports this one): the id and the file come from the environment.
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const { WEBHOOK_ID, SEEN_FILE } = process.env;
if (process.argv.length !== 2 || !WEBHOOK_ID || !SEEN_FILE || /[\s\x00-\x1f]/.test(WEBHOOK_ID)) {
console.error('Usage: WEBHOOK_ID=... SEEN_FILE=... node webhook-once.mjs');
process.exit(2);
}
console.log(isNew(SEEN_FILE, WEBHOOK_ID) ? 'new' : 'duplicate');
}
WEBHOOK_ID=whd_01k6c3s0v1m2n3p4q5r6s7t8v9 SEEN_FILE=seen-webhooks.txt python3 webhook-once.py
# Handle each webhook once: keep the id of every webhook you handled and skip a repeat.
#
# Usage: WEBHOOK_ID=whd_... SEEN_FILE=seen-webhooks.txt python3 webhook-once.py
# WEBHOOK_ID is the Webhook-Id header of the request: the same on every delivery of one event (a "Send again", a lost
# answer or a retry sends it again). SEEN_FILE is a text file with one handled id per line; it is created when it is
# missing. Prints "new" the first time an id is seen (and remembers it) and "duplicate" after that. Both exit 0.
# No network call. Needs Python 3.9 (nothing to install).
#
# Note: a file is enough to see the idea. In your real server keep the ids in your database under a UNIQUE key, so two
# deliveries that arrive at the same moment can never both be new.
import os
import re
import sys
def is_new(seen_file, webhook_id):
"""True the first time webhook_id is seen (it is written to seen_file), False for every repeat."""
seen = []
if os.path.isfile(seen_file):
with open(seen_file, encoding="utf-8") as handle:
seen = handle.read().split("\n")
if webhook_id in seen:
return False
with open(seen_file, "a", encoding="utf-8") as handle:
handle.write(webhook_id + "\n")
return True
# Run as a program (not when another file imports this one): the id and the file come from the environment.
if __name__ == "__main__":
webhook_id = os.environ.get("WEBHOOK_ID", "")
seen_file = os.environ.get("SEEN_FILE", "")
if len(sys.argv) != 1 or not webhook_id or not seen_file or re.search(r"[\s\x00-\x1f]", webhook_id):
print("Usage: WEBHOOK_ID=... SEEN_FILE=... python3 webhook-once.py", file=sys.stderr)
sys.exit(2)
print("new" if is_new(seen_file, webhook_id) else "duplicate")
What we do with your answer#
| Your server | What we do |
|---|---|
Answers 2xx within 10 seconds |
The delivery is done. We do not send it again. |
| Answers with any other status | The try failed. |
| Answers with a redirect | We do not follow it. The try failed. |
| Does not answer within 10 seconds, or cannot be reached | The try failed. |
| A try failed | We send each event once, so the delivery shows as failed on the Developers page of your panel and on the deposit or withdrawal, where "Send again" sends it once more. With Automatic retries on we try again after a wait instead (below). |
We send each event once. A try that is not answered with a 2xx within 10 seconds fails,
and the delivery shows as failed on your panel's Developers page and on the deposit or withdrawal, where "Send again"
sends it once more. Turn on Automatic retries on the Developers page to have us try
12 times over about 2 days instead. The wait before each next try is 1 minute, 5 minutes, 15 minutes, 30 minutes, 1 hour, 2 hours, 4 hours, 8 hours, 12 hours, 12 hours, 12 hours;
"Send again" then starts another round of 12 tries. Automatic retries are off until you turn them on.
The rules to follow#
-
Answer with any
2xxquickly, within 10 seconds, and do the slow work afterwards. Anything else (another status, a redirect, a timeout) counts as a failed try. -
The same event can arrive more than once. A "Send again", an answer of yours that got lost on the
way, or (with Automatic retries on) a retry delivers the same event again. De-duplicate by
Webhook-Id(it is also theidin the body; the same on every delivery of one event). -
Events can arrive out of order. For one deposit, ignore an event whose
data.versionis lower than one you already processed. -
Check
mode. You have two webhook secrets: test webhooks are signed with the test one, live webhooks with the live one (both on the Developers page). Verify each webhook with the secret of itsmode, so a test webhook can never pass as a live one. -
We never send webhooks to
localhost, internal names or private IP addresses, and we do not follow redirects. A webhook that failed can be sent again from the Developers page of your panel or from the deposit or withdrawal ("Send again"), always to the samewebhook_url.
6Confirm with a GET#
GET https://api.no1paytest.com/v1/deposits/{id} returns the deposit object of the webhook's data and
adds payer: the card number the customer paid from (a card payment), or the account number or Sheba the
customer typed on a bank transfer page, with its bank, in full. A webhook never carries payer. Do it
before you deliver the order: check that status is paid, and that reference and
amount are what you expect for the order. A key finds only deposits of its own mode and merchant; any
other id answers deposit_not_found.
bash get-deposit.sh "$DEPOSIT_ID"
#!/usr/bin/env bash
# Get a deposit and look at its status. Check that it is "paid" before you deliver the order.
#
# Usage: API_URL=https://api.example.com API_KEY=sk_test_... ./get-deposit.sh <deposit-id>
# Example: ./get-deposit.sh dep_01k6c3s0v1m2n3p4q5r6s7t8v9
#
# API_URL and API_KEY come from the environment, never from this file.
set -euo pipefail
usage() {
echo "Usage: API_URL=... API_KEY=... $0 <deposit-id>" >&2
exit 2
}
[ "$#" -eq 1 ] || usage
[ -n "${API_URL:-}" ] && [ -n "${API_KEY:-}" ] || usage
deposit_id="$1"
[[ "$deposit_id" =~ ^[A-Za-z0-9_]+$ ]] || usage
answer=$(mktemp)
trap 'rm -f "$answer"' EXIT
status=$(curl --silent --show-error --output "$answer" --write-out '%{http_code}' \
"$API_URL/v1/deposits/$deposit_id" \
--header "Authorization: Bearer $API_KEY")
# Read one text member of an error answer (an error is JSON with "code", "detail" and "request_id").
field() {
sed -nE 's/.*"'"$1"'":"(([^"\\]|\\.)*)".*/\1/p' "$answer" | head -n 1 | sed -E 's/\\(.)/\1/g'
}
if [[ "$status" == 2* ]]; then
cat "$answer"
echo
else
echo "Error $(field code): $(field detail) (request_id $(field request_id))" >&2
exit 1
fi
php get-deposit.php "$DEPOSIT_ID"
<?php
// Get a deposit and look at its status. Check that it is "paid" before you deliver the order.
//
// Usage: API_URL=https://api.example.com API_KEY=sk_test_... php get-deposit.php <deposit-id>
// Example: php get-deposit.php dep_01k6c3s0v1m2n3p4q5r6s7t8v9
//
// API_URL and API_KEY come from the environment, never from this file. Needs PHP 8.4 with the curl extension.
if ($argc !== 2 || ! getenv('API_URL') || ! getenv('API_KEY')) {
fwrite(STDERR, "Usage: API_URL=... API_KEY=... php get-deposit.php <deposit-id>\n");
exit(2);
}
[, $depositId] = $argv;
$curl = curl_init(rtrim((string) getenv('API_URL'), '/').'/v1/deposits/'.rawurlencode($depositId));
curl_setopt_array($curl, [
CURLOPT_HTTPHEADER => ['Authorization: Bearer '.getenv('API_KEY')],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
]);
$answer = curl_exec($curl);
if ($answer === false) {
fwrite(STDERR, 'Error connection_failed: '.curl_error($curl)." (request_id none)\n");
exit(1);
}
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
if ($status >= 200 && $status < 300) {
echo $answer, "\n";
exit(0);
}
// An error is JSON with "code", "detail" and "request_id".
$problem = json_decode($answer, true) ?: [];
fwrite(STDERR, sprintf("Error %s: %s (request_id %s)\n", $problem['code'] ?? 'unknown', $problem['detail'] ?? 'no detail', $problem['request_id'] ?? 'none'));
exit(1);
node get-deposit.mjs "$DEPOSIT_ID"
// Get a deposit and look at its status. Check that it is "paid" before you deliver the order.
//
// Usage: API_URL=https://api.example.com API_KEY=sk_test_... node get-deposit.mjs <deposit-id>
// Example: node get-deposit.mjs dep_01k6c3s0v1m2n3p4q5r6s7t8v9
//
// API_URL and API_KEY come from the environment, never from this file. Needs Node.js 24 (nothing to install).
const { API_URL, API_KEY } = process.env;
const [depositId] = process.argv.slice(2);
if (process.argv.length !== 3 || !API_URL || !API_KEY) {
console.error('Usage: API_URL=... API_KEY=... node get-deposit.mjs <deposit-id>');
process.exit(2);
}
let response;
try {
response = await fetch(`${API_URL.replace(/\/$/, '')}/v1/deposits/${encodeURIComponent(depositId)}`, {
headers: { Authorization: `Bearer ${API_KEY}` },
signal: AbortSignal.timeout(30_000),
});
} catch (error) {
console.error(`Error connection_failed: ${error.cause?.message ?? error.message} (request_id none)`);
process.exit(1);
}
const answer = await response.text();
if (response.ok) {
console.log(answer);
} else {
// An error is JSON with "code", "detail" and "request_id".
let problem = {};
try {
problem = JSON.parse(answer);
} catch {}
console.error(`Error ${problem.code ?? 'unknown'}: ${problem.detail ?? 'no detail'} (request_id ${problem.request_id ?? 'none'})`);
process.exit(1);
}
python3 get-deposit.py "$DEPOSIT_ID"
# Get a deposit and look at its status. Check that it is "paid" before you deliver the order.
#
# Usage: API_URL=https://api.example.com API_KEY=sk_test_... python3 get-deposit.py <deposit-id>
# Example: python3 get-deposit.py dep_01k6c3s0v1m2n3p4q5r6s7t8v9
#
# API_URL and API_KEY come from the environment, never from this file. Needs Python 3.9 (nothing to install).
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
api_url = os.environ.get("API_URL", "")
api_key = os.environ.get("API_KEY", "")
if len(sys.argv) != 2 or not api_url or not api_key:
print("Usage: API_URL=... API_KEY=... python3 get-deposit.py <deposit-id>", file=sys.stderr)
sys.exit(2)
deposit_id = sys.argv[1]
request = urllib.request.Request(
api_url.rstrip("/") + "/v1/deposits/" + urllib.parse.quote(deposit_id, safe=""),
headers={"Authorization": "Bearer " + api_key},
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
print(response.read().decode("utf-8"))
except urllib.error.HTTPError as error:
# An error is JSON with "code", "detail" and "request_id".
try:
problem = json.loads(error.read().decode("utf-8"))
except ValueError:
problem = {}
print(
"Error %s: %s (request_id %s)"
% (problem.get("code", "unknown"), problem.get("detail", "no detail"), problem.get("request_id", "none")),
file=sys.stderr,
)
sys.exit(1)
except (urllib.error.URLError, OSError) as error:
print("Error connection_failed: %s (request_id none)" % error, file=sys.stderr)
sys.exit(1)
The answer of a deposit that was paid:
200 A paid deposit
{
"id": "dep_01k6c3s0v1m2n3p4q5r6s7t8v9",
"object": "deposit",
"mode": "test",
"status": "paid",
"method": "account_transfer",
"amount": 1500000,
"currency": "IRR",
"amount_rials": 1500000,
"fee_rials": 22500,
"reference": "order-10542",
"customer": {
"id": "customer-77",
"level": "default"
},
"payer": {
"card_number": null,
"account": null,
"bank": null
},
"redirect_url": "https://shop.example/orders/order-10542/return",
"webhook_url": "https://shop.example/webhooks/payments",
"allowed_methods": null,
"failure_code": null,
"failure_message": null,
"version": 2,
"created_at": "2026-10-03T09:30:00Z",
"updated_at": "2026-10-03T09:30:12Z",
"expires_at": "2026-10-03T10:00:00Z",
"paid_at": "2026-10-03T09:30:12Z"
}
A deposit that is expired or failed can still become paid (a transfer made in
time that we confirm late). Always credit the order on deposit.paid, even if you already showed the
customer that the payment had failed.
7Test it end to end#
A deposit made with a test key is a test deposit. Its payment page says "Test payment" and has three buttons (paid,
failed, expired) instead of a real payment, and for a bank account transfer it shows a made-up account. A test deposit
never reaches a bank. A card-to-card test deposit shows the real card payment page, step by step, and takes only the
made-up test numbers of step 8. On a bank_gateway test deposit, "Go to the bank
gateway" opens a test bank page instead of a real one, where you choose whether the bank pays, refuses, or the customer
leaves (the deposit stays open). Run this once, with your test key, to see every part work:
- Create a deposit with your test key (step 2) and keep its
payment_url. - Open
payment_urlin a browser. You see the test payment page. -
Press the paid button, or end the deposit from your server with the simulate call below. A
card_to_carddeposit (itsamountinside your card-to-card limits: 5,000,000 to 50,000,000 rials by default) can also be paid on the page itself, step by step: type the test card0000 0000 0000 0018, any expiry and CVV2, then the code111111at every code step (step 8). - Your webhook URL receives a signed
deposit.paidwith"mode": "test". Verify it (step 5). - Get the deposit (step 6): its
statusispaidand itsversionis 2.
To end the deposit from your server instead of pressing the button:
| Field | Type | Required | What it is |
|---|---|---|---|
outcome |
string | Required |
How the test deposit ends: paid, failed or expired.
One of
paid,
failed,
expired.
|
method |
string | Optional |
Only for a test deposit still waiting for its method (made without method): the method the customer would pick. paid and failed need it, expired does not, and a deposit that already has a method refuses it.
One of
account_transfer,
pol_transfer,
card_to_card,
bank_gateway.
|
{
"outcome": "paid"
}
bash simulate-deposit.sh "$DEPOSIT_ID" paid
#!/usr/bin/env bash
# Test mode only: end a test deposit the way a real payment would (paid, failed or expired).
#
# Usage: API_URL=https://api.example.com API_KEY=sk_test_... ./simulate-deposit.sh <deposit-id> <paid|failed|expired>
# Example: ./simulate-deposit.sh dep_01k6c3s0v1m2n3p4q5r6s7t8v9 paid
#
# API_URL and API_KEY come from the environment, never from this file.
# A live key is refused (test_mode_only): the simulator never touches real money.
set -euo pipefail
usage() {
echo "Usage: API_URL=... API_KEY=... $0 <deposit-id> <paid|failed|expired>" >&2
exit 2
}
[ "$#" -eq 2 ] || usage
[ -n "${API_URL:-}" ] && [ -n "${API_KEY:-}" ] || usage
deposit_id="$1"
outcome="$2"
[[ "$deposit_id" =~ ^[A-Za-z0-9_]+$ ]] || usage
case "$outcome" in paid | failed | expired) ;; *) usage ;; esac
answer=$(mktemp)
trap 'rm -f "$answer"' EXIT
# Asking again with the same key and outcome gives the same answer instead of a second change.
status=$(curl --silent --show-error --output "$answer" --write-out '%{http_code}' \
--request POST "$API_URL/v1/deposits/$deposit_id/simulate" \
--header "Authorization: Bearer $API_KEY" \
--header "Idempotency-Key: simulate-$deposit_id-$outcome" \
--header "Content-Type: application/json" \
--data "{\"outcome\":\"$outcome\"}")
# Read one text member of an error answer (an error is JSON with "code", "detail" and "request_id").
field() {
sed -nE 's/.*"'"$1"'":"(([^"\\]|\\.)*)".*/\1/p' "$answer" | head -n 1 | sed -E 's/\\(.)/\1/g'
}
if [[ "$status" == 2* ]]; then
cat "$answer"
echo
else
echo "Error $(field code): $(field detail) (request_id $(field request_id))" >&2
exit 1
fi
php simulate-deposit.php "$DEPOSIT_ID" paid
<?php
// Test mode only: end a test deposit the way a real payment would (paid, failed or expired).
//
// Usage: API_URL=https://api.example.com API_KEY=sk_test_... php simulate-deposit.php <deposit-id> <paid|failed|expired>
// Example: php simulate-deposit.php dep_01k6c3s0v1m2n3p4q5r6s7t8v9 paid
//
// API_URL and API_KEY come from the environment, never from this file. Needs PHP 8.4 with the curl extension.
// A live key is refused (test_mode_only): the simulator never touches real money.
if ($argc !== 3 || ! getenv('API_URL') || ! getenv('API_KEY') || ! in_array($argv[2], ['paid', 'failed', 'expired'], true)) {
fwrite(STDERR, "Usage: API_URL=... API_KEY=... php simulate-deposit.php <deposit-id> <paid|failed|expired>\n");
exit(2);
}
[, $depositId, $outcome] = $argv;
// Asking again with the same key and outcome gives the same answer instead of a second change.
$curl = curl_init(rtrim((string) getenv('API_URL'), '/').'/v1/deposits/'.rawurlencode($depositId).'/simulate');
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode(['outcome' => $outcome]),
CURLOPT_HTTPHEADER => [
'Authorization: Bearer '.getenv('API_KEY'),
'Idempotency-Key: simulate-'.$depositId.'-'.$outcome,
'Content-Type: application/json',
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
]);
$answer = curl_exec($curl);
if ($answer === false) {
fwrite(STDERR, 'Error connection_failed: '.curl_error($curl)." (request_id none)\n");
exit(1);
}
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
if ($status >= 200 && $status < 300) {
echo $answer, "\n";
exit(0);
}
// An error is JSON with "code", "detail" and "request_id".
$problem = json_decode($answer, true) ?: [];
fwrite(STDERR, sprintf("Error %s: %s (request_id %s)\n", $problem['code'] ?? 'unknown', $problem['detail'] ?? 'no detail', $problem['request_id'] ?? 'none'));
exit(1);
node simulate-deposit.mjs "$DEPOSIT_ID" paid
// Test mode only: end a test deposit the way a real payment would (paid, failed or expired).
//
// Usage: API_URL=https://api.example.com API_KEY=sk_test_... node simulate-deposit.mjs <deposit-id> <paid|failed|expired>
// Example: node simulate-deposit.mjs dep_01k6c3s0v1m2n3p4q5r6s7t8v9 paid
//
// API_URL and API_KEY come from the environment, never from this file. Needs Node.js 24 (nothing to install).
// A live key is refused (test_mode_only): the simulator never touches real money.
const { API_URL, API_KEY } = process.env;
const [depositId, outcome] = process.argv.slice(2);
if (process.argv.length !== 4 || !API_URL || !API_KEY || !['paid', 'failed', 'expired'].includes(outcome)) {
console.error('Usage: API_URL=... API_KEY=... node simulate-deposit.mjs <deposit-id> <paid|failed|expired>');
process.exit(2);
}
// Asking again with the same key and outcome gives the same answer instead of a second change.
let response;
try {
response = await fetch(`${API_URL.replace(/\/$/, '')}/v1/deposits/${encodeURIComponent(depositId)}/simulate`, {
method: 'POST',
headers: {
Authorization: `Bearer ${API_KEY}`,
'Idempotency-Key': `simulate-${depositId}-${outcome}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ outcome }),
signal: AbortSignal.timeout(30_000),
});
} catch (error) {
console.error(`Error connection_failed: ${error.cause?.message ?? error.message} (request_id none)`);
process.exit(1);
}
const answer = await response.text();
if (response.ok) {
console.log(answer);
} else {
// An error is JSON with "code", "detail" and "request_id".
let problem = {};
try {
problem = JSON.parse(answer);
} catch {}
console.error(`Error ${problem.code ?? 'unknown'}: ${problem.detail ?? 'no detail'} (request_id ${problem.request_id ?? 'none'})`);
process.exit(1);
}
python3 simulate-deposit.py "$DEPOSIT_ID" paid
# Test mode only: end a test deposit the way a real payment would (paid, failed or expired).
#
# Usage: API_URL=https://api.example.com API_KEY=sk_test_... python3 simulate-deposit.py <deposit-id> <paid|failed|expired>
# Example: python3 simulate-deposit.py dep_01k6c3s0v1m2n3p4q5r6s7t8v9 paid
#
# API_URL and API_KEY come from the environment, never from this file. Needs Python 3.9 (nothing to install).
# A live key is refused (test_mode_only): the simulator never touches real money.
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
api_url = os.environ.get("API_URL", "")
api_key = os.environ.get("API_KEY", "")
if len(sys.argv) != 3 or not api_url or not api_key or sys.argv[2] not in ("paid", "failed", "expired"):
print("Usage: API_URL=... API_KEY=... python3 simulate-deposit.py <deposit-id> <paid|failed|expired>", file=sys.stderr)
sys.exit(2)
deposit_id = sys.argv[1]
outcome = sys.argv[2]
# Asking again with the same key and outcome gives the same answer instead of a second change.
request = urllib.request.Request(
api_url.rstrip("/") + "/v1/deposits/" + urllib.parse.quote(deposit_id, safe="") + "/simulate",
data=json.dumps({"outcome": outcome}).encode("utf-8"),
method="POST",
headers={
"Authorization": "Bearer " + api_key,
"Idempotency-Key": "simulate-" + deposit_id + "-" + outcome,
"Content-Type": "application/json",
},
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
print(response.read().decode("utf-8"))
except urllib.error.HTTPError as error:
# An error is JSON with "code", "detail" and "request_id".
try:
problem = json.loads(error.read().decode("utf-8"))
except ValueError:
problem = {}
print(
"Error %s: %s (request_id %s)"
% (problem.get("code", "unknown"), problem.get("detail", "no detail"), problem.get("request_id", "none")),
file=sys.stderr,
)
sys.exit(1)
except (urllib.error.URLError, OSError) as error:
print("Error connection_failed: %s (request_id none)" % error, file=sys.stderr)
sys.exit(1)
The answer is the deposit as it is now (here after paid: version 2, with the fee and paid_at); the same shape as GET:
200 The deposit after paid
{
"id": "dep_01k6c3s0v1m2n3p4q5r6s7t8v9",
"object": "deposit",
"mode": "test",
"status": "paid",
"method": "account_transfer",
"amount": 1500000,
"currency": "IRR",
"amount_rials": 1500000,
"fee_rials": 22500,
"reference": "order-10542",
"customer": {
"id": "customer-77",
"level": "default"
},
"payer": {
"card_number": null,
"account": null,
"bank": null
},
"redirect_url": "https://shop.example/orders/order-10542/return",
"webhook_url": "https://shop.example/webhooks/payments",
"allowed_methods": null,
"failure_code": null,
"failure_message": null,
"version": 2,
"created_at": "2026-10-03T09:30:00Z",
"updated_at": "2026-10-03T09:30:12Z",
"expires_at": "2026-10-03T10:00:00Z",
"paid_at": "2026-10-03T09:30:12Z"
}
What each outcome does, the same as for a real payment:
| Outcome you send | The deposit becomes | Webhook | failure_code |
|---|---|---|---|
paid |
paid |
deposit.paid |
none: our fee is taken from your test balance |
failed |
failed |
deposit.failed |
payment_failed |
expired |
expired |
deposit.expired |
deposit_expired |
-
A test deposit made without
method(step 3) iswaiting_for_methoduntil its method is picked. On the test payment page the customer picks it; from your server, sendmethodwithpaidorfailed: the simulator picks it first, then ends the deposit (version3).expiredneeds no method, and a deposit that already has a method refuses one. A method that is not available for this deposit (not in itsallowed_methods, turned off, blocked for the customer or outside its limits) answersmethod_unavailable. -
A test deposit nobody ends expires at
expires_at, like a live one. After that,paidandfailedare refused: it can only expire. -
A live key is refused with
test_mode_only, and a test key never finds a live deposit (deposit_not_found). A deposit that already ended answersdeposit_not_open. -
Test webhooks are signed with your test webhook secret, never the live one, and go to the
webhook_urlyou sent with that test deposit.
8Test data#
A test key takes only the numbers on this page: the test cards, the test Shebas and the test tracking numbers. Anything else (a real card, a real Sheba, a real tracking number) is refused in test mode, so no real number ever enters your test data. A live key does the opposite: a test card or a test Sheba is refused. Every number has a copy button; write a card with or without spaces.
Test cards, for card-to-card deposits#
Create a deposit with a test key, method card_to_card (or let the customer
pick it, step 3) and an amount inside your card-to-card limits (5,000,000 to 50,000,000 rials by default, set on
your Limits page; outside them the create answers amount_outside_method_limits and the picker does not offer
card-to-card), and open its payment_url. The page is the real card payment page, step by
step, marked "Test payment", with this list on it. On the card form type a test card, any expiry month and
year, any CVV2 of 3 or 4 digits, the mobile 09000000000 and the national id
0000000019. The payment runs inside our system: no bank is called, and none
of what you type is kept. Any other card number is refused on the form.
| Test card | What happens | Steps after the card form | Ends as |
|---|---|---|---|
0000 0000 0000 0018 |
Every step works and the deposit is paid. Use this card for the happy path. | login code, then captcha, then bank page code, then dynamic password | paiddeposit.paid |
0000 0000 0000 0026 |
Goes straight to the dynamic password and is paid: the short way to a paid deposit. | dynamic password | paiddeposit.paid |
0000 0000 0000 0034 |
The card is refused as soon as the customer sends the card form, so there is no step after it. | none | faileddeposit.failedfailure_code card_transfer_failed |
0000 0000 0000 0042 |
Every step works, then the bank refuses the transfer because the card has not enough balance. | login code, then captcha, then bank page code, then dynamic password | faileddeposit.failedfailure_code payment_failed |
Test codes and the captcha#
The same two codes work at every code step (the login code, the bank page code and the dynamic password). The login code has 6 digits.
| Code | At the login code and the bank page code | At the dynamic password |
|---|---|---|
111111 |
The right code: the payment goes on to the next step. | The right code: the card's own outcome from the table above. |
000000 |
A wrong code: the page stays on the step and says so; type again. | A wrong code: the deposit fails, payment_failed, whatever the card. |
- Any other code is refused on the page with a message, and changes nothing: no step is used up.
- The captcha shows nine pictures, numbered 1 to 9 from left to right and top to bottom. Pick the three stars: pictures 1, 5 and 9. Any other picks show the same pictures again with a message; "new images" shows the same pictures again.
- "Send the code again" works after the countdown on the page, and starts the countdown again.
Test Shebas, for withdrawals#
A test withdrawal (step 12) takes one of these Shebas in destination.sheba and any test
card of the table above in destination.card_number (for a withdrawal the card's outcome does not matter: the
Sheba decides). The same three Shebas are the customer's own Sheba on a test bank account transfer or pol page, where the
outcome does not matter.
| Test Sheba | What happens to the test withdrawal | Ends as |
|---|---|---|
IR220000000000000000000001 |
Paid by itself, 10 seconds to about a minute after you make it, even if it still waits for approval in your panel. | paidwithdrawal.paid |
IR920000000000000000000002 |
Failed by itself, 10 seconds to about a minute after you make it. | failedwithdrawal.failedfailure_code payment_failed |
IR650000000000000000000003 |
Nothing happens by itself: end it with the simulate call. Your panel can approve or hold a test withdrawal, never end it. | not by itselfno webhook |
The simulate call (step 7) still ends any test withdrawal first, whatever its Sheba.
Test tracking numbers, for bank transfers#
On the payment page of a test account_transfer or pol_transfer deposit
the customer sends the details of their transfer: the tracking number from the bank's receipt, and their own Sheba: in test
mode only a test Sheba of the table above (an account number is refused). The tracking number decides what happens at once. A
pol deposit still keeps only the last 4 digits of the number; the whole number is compared. Persian digits and spaces are read;
dashes only on a pol page.
| Tracking number | What happens to the deposit | Ends as |
|---|---|---|
10000001 |
The deposit is paid at once, as if its bank row had arrived. | paiddeposit.paid |
10000002 |
The deposit fails at once. | faileddeposit.failedfailure_code payment_failed |
10000003 |
The deposit stays pending: no bank row ever comes. The simulate call or its expiry ends it, and the customer may send other details meanwhile. | pendingno webhook |
The other endings#
-
Expired. Stop at any step, or send nothing: the deposit expires at its
expires_at, and a card payment also after the idle minutes set on your Limits page (10 by default) with nothing done on the page:deposit_expiredanddeposit.expired. The page's expired button andexpiredin the simulate call end it at once. -
Cancelled. Press Cancel on the card payment page before the dynamic password, or on a bank transfer
or gateway page before you send the transfer's details or start the gateway payment:
deposit_cancelledanddeposit.cancelled. -
Unknown never happens in test mode. A deposit is
unknownonly while we check a real payment whose result is not clear.
What is refused#
With a test key, POST https://api.no1paytest.com/v1/withdrawals refuses a card number that is not a test card, a Sheba that is
not a test Sheba and any destination.account_number (test data has no bank account numbers: leave it out) on the
field, with validation_failed. With a live key it refuses a test card and a test
Sheba the same way. Nothing is created. The messages:
- Test key, another card: A test key takes only the test cards of the guide (Test data) in destination.card_number.
- Test key, another Sheba: A test key takes only the test Shebas of the guide (Test data) in destination.sheba.
- Live key, a test card: destination.card_number is a test card: it works only with a test key.
- Live key, a test Sheba: destination.sheba is a test Sheba: it works only with a test key.
- Test key, any
destination.account_number: A test key takes no destination.account_number: leave it out (test data has no bank account numbers).
422 A test key with a card that is not a test card
{
"type": "https://docs.no1paytest.com/errors/validation_failed",
"status": 422,
"code": "validation_failed",
"title": "The request is not valid",
"detail": "Fix the fields listed in \"errors\" and send the request again.",
"errors": {
"destination.card_number": [
"A test key takes only the test cards of the guide (Test data) in destination.card_number."
]
},
"request_id": "req_01k6c0z7x4m2p9q8r5s3t1v0w2"
}
422 A live key with a test card
{
"type": "https://docs.no1paytest.com/errors/validation_failed",
"status": 422,
"code": "validation_failed",
"title": "The request is not valid",
"detail": "Fix the fields listed in \"errors\" and send the request again.",
"errors": {
"destination.card_number": [
"destination.card_number is a test card: it works only with a test key."
]
},
"request_id": "req_01k6c0z7x4m2p9q8r5s3t1v0w2"
}
On the payment pages the same rules hold in the page's language: a test deposit's card form, tracking number and Sheba take only the test data, and a live payment page refuses a test card and a test Sheba. A test tracking number is never refused in live mode: a real bank reference may be the same digits.
9Statuses and version#
A deposit's status is one of these. The last column shows where it can go next, so you know which
webhooks are still possible.
| Status | What it means | Can become |
|---|---|---|
waiting_for_method |
Made without method: the customer has not picked a method yet, and method is null. No webhook is sent when they pick or go back. |
pending,
expired,
cancelled
|
pending |
Waiting for the customer to pay with its method. Until money may move, a customer who was offered the choice can go back to the method selection page (the deposit is then waiting_for_method again). |
paid,
failed,
expired,
cancelled,
unknown,
waiting_for_method
|
paid |
The money arrived. Credit the order. | nothing, final |
failed |
Not paid. A payment made in time can still arrive late and make it paid; a card-to-card deposit can also become paid after its receipt was checked. |
paid
|
expired |
Not paid in time. A payment made in time can still be confirmed late and make it paid. |
paid
|
cancelled |
Not paid. A card-to-card deposit can still become paid after its receipt was checked; nothing else changes it. |
paid
|
unknown |
We are checking a payment whose result is not clear. No webhook is sent when a deposit becomes unknown; the next one says how it was settled. |
paid,
failed
|
A withdrawal's status is one of these:
| Status | What it means | Can become |
|---|---|---|
waiting_for_approval |
Your team has not approved it yet in your panel. |
open,
cancelled
|
open |
Approved, waiting to be paid. |
paying,
paid,
cancelled,
failed,
on_hold
|
paying |
A bank transfer for it is on its way. |
paid,
open,
on_hold,
unknown
|
on_hold |
Held in your panel: nothing is paid until it is released. |
open,
cancelled
|
paid |
All of it was paid, final. | nothing, final |
failed |
Test mode only: ended as failed by the simulator, final. | nothing, final |
cancelled |
Cancelled, final. It may have been paid in part: see paid_amount_rials. | nothing, final |
unknown |
We are checking whether the last transfer went through. |
paid,
open,
cancelled
|
"Final" means our system never changes it again by itself. In rare cases a person on your team, or on ours,
corrects a finished deposit or withdrawal by hand after checking the proof (for example a payment that really
arrived although it shows as failed). A correction raises version and sends no webhook on its own:
we send the event of the new status afterwards, when the person decides to (a withdrawal moved back to an open state has no event: read it with GET /v1/withdrawals/{id}). Always act on the highest
version you have seen, and before you ship goods for a deposit.paid confirm with
GET /v1/deposits/{id}, as step 6 says.
The webhook events we send:
| Event | About | When |
|---|---|---|
deposit.paid |
deposit | The money arrived. Credit the order. Also sent when an expired, failed or cancelled deposit is paid late, so it may come after deposit.failed or deposit.cancelled for the same deposit: compare version. |
deposit.failed |
deposit | The payment did not go through; failure_code says why. |
deposit.expired |
deposit | The customer did not pay in time (failure_code deposit_expired). Also sent when the customer never picked a method: method is then null. |
deposit.cancelled |
deposit | The customer cancelled on the payment page, or your team released the withdrawal it was paying (failure_code deposit_cancelled). |
withdrawal.paid |
withdrawal | All of it was paid. Never sent for a withdrawal made by hand in your panel. |
withdrawal.failed |
withdrawal | A test withdrawal ended as failed, by the simulator or by itself with a test Sheba (failure_code payment_failed). Test mode only. |
withdrawal.cancelled |
withdrawal | Your team cancelled it. It may have been paid in part: see paid_amount_rials. Never sent for a withdrawal made by hand in your panel. |
Every deposit and withdrawal also has a version: it starts at 1 and goes up by 1 on every status change.
A higher version is the newer state, whether it came in a webhook or from a GET. When a deposit is
failed, expired or cancelled, failure_code says why (for example
deposit_expired or payment_failed, and deposit_cancelled
on a cancelled one).
A card_to_card deposit also expires when the customer does nothing on the payment page for your card
idle minutes (set on the Limits page of your panel), which can be before expires_at, and it is never
expired while its card transfer is being made or checked.
10Errors#
Every error is application/problem+json. This is what a deposit without an amount gets:
{
"type": "https://docs.no1paytest.com/errors/validation_failed",
"status": 422,
"code": "validation_failed",
"title": "The request is not valid",
"detail": "Fix the fields listed in \"errors\" and send the request again.",
"errors": {
"amount": [
"Fill in amount."
]
},
"request_id": "req_01k6c0z7x4m2p9q8r5s3t1v0w2"
}
- Read
code, nevertitleordetail: the words may change, the codes do not. errorsis there for a validation error and names each field that was refused.- Some errors carry more members that help you fix them (for example
suggested_amountsonmethod_unavailable,next_allowed_atoncustomer_has_recent_withdrawal). -
Wait and try again on
rate_limit_exceeded(you may make 600 calls a minute in each mode; the answer has aRetry-Afterheader) and on a5xxsuch asinternal_error; fix your request on a4xx.
request_id is how we find your call. Every answer, success or error, also has it in the
X-Request-Id header. When something goes wrong and you write to us, send the request_id:
we find your call, what you sent and what we answered. For a call refused before your key was checked (a wrong
or missing key), refused for its IP or because live mode is off, or answered
rate_limit_exceeded, we keep only its status and code, not the bodies, and only a limited
number of them a minute.
Every code, with how to fix it. A code with no HTTP status is never an error answer: it is only the
failure_code of a deposit (or a test withdrawal) that ended without being paid, read in the deposit and
in its webhook (step 9).
| Code | HTTP | What it means | What to do |
|---|---|---|---|
bad_request |
400 | Bad request | Check the request format (JSON body, headers) against the docs. |
validation_failed |
422 | The request is not valid | Fix the fields listed in "errors" and send the request again. |
authentication_failed |
401 | Authentication failed | Send a valid secret key in the Authorization header: "Bearer sk_live_…" or "Bearer sk_test_…". |
permission_denied |
403 | Permission denied | Use a key or user that is allowed to do this action. |
not_found |
404 | Not found | Check the id and the URL. |
method_not_allowed |
405 | Method not allowed | Use the HTTP method listed for this endpoint in the docs. |
idempotency_key_reused |
409 | Idempotency key was already used with a different request | Use a new Idempotency-Key for a different request, or resend exactly the same body. |
method_unavailable |
422 | This payment method is not available right now | Try another method or amount, or try again later. For a card_to_card deposit the answer also has suggested_amounts: up to 3 amounts, in the currency you sent, that could be paid right now (closest first; empty when none). |
deposit_not_found |
404 | Deposit not found | Check the deposit id. You can only see deposits of your own merchant account. |
payload_too_large |
413 | Request body is too large | Send a smaller request body. |
rate_limit_exceeded |
429 | Too many requests | Wait for the time in the Retry-After header, then retry. |
internal_error |
500 | Internal error | Retry later. If it keeps happening, contact support with the request_id. |
service_unavailable |
503 | Service temporarily unavailable | Wait for the time in the Retry-After header, then retry. |
device_disabled |
403 | This phone is disabled in the panel | SMS relay app only: enable the phone again in the panel; the app starts sending by itself. |
deposit_expired |
none: a failure_code |
The deposit expired before it was paid | The customer did not pay in time. Create a new deposit if they still want to pay. A payment made in time that we confirm late still makes the deposit paid, so always credit the order on deposit.paid. |
live_mode_disabled |
403 | Live mode is not turned on for this merchant | Your live keys (sk_live_…) work once live mode is turned on for your merchant account; ask us to turn it on when your integration is ready. Until then use a test key (sk_test_…): test deposits never move real money. |
test_mode_only |
403 | This works only in test mode | Simulate only test deposits and test withdrawals, with a test key (sk_test_…). A live deposit is paid only by the customer's real payment, a live withdrawal only by a real transfer. |
deposit_not_open |
409 | The deposit is not open any more | The deposit is already paid, failed, expired or cancelled, so it cannot change any more, or its time ran out (expires_at passed), so it can only expire. Get it with GET /v1/deposits/{id} to see how it ended; create a new deposit to try again. |
payment_failed |
none: a failure_code |
The payment did not go through | The customer's payment did not go through. Create a new deposit if they want to try again. In test mode the simulator's "failed" outcome ends a deposit, or a test withdrawal, with this code. |
unknown_user_level |
422 | There is no user level with this name | Send in customer.level the name of one of your user levels, as listed in your panel (Customers, Levels; upper or lower case does not matter), or leave customer.level out: the customer then gets the level set for them in the panel, or your default level. Levels are the same in test and live mode. |
amount_outside_level_limits |
422 | The amount is outside the limits of the customer's user level | Each user level may have a smallest and a largest deposit amount (in rials, set in your panel: Customers, Levels). Ask the customer for an amount inside those limits, or change the limits or the customer's level in the panel. The detail names the level and its limits. |
customer_blocked |
403 | This customer is blocked | The customer (customer.id) is blocked in your panel (Customers), so they cannot make new deposits or get new withdrawals. Unblock them in the panel if this was a mistake. Test and live customers are blocked separately. |
customer_method_blocked |
403 | This customer is blocked from this payment method | The customer (customer.id) is blocked from this method (method) in your panel (Customers), so they cannot make a deposit with it, or blocked from withdrawals, so they cannot get a new withdrawal. Offer the customer another method, or remove the block in the panel if this was a mistake. Test and live customers are blocked separately. |
gateway_unavailable |
none: a failure_code |
No bank gateway could take this payment | A deposit's failure_code: when the customer started the bank gateway payment, none of your gateway terminals could take it (all turned off, none serves the customer's user level, or the chosen one just reached its daily amount cap and was turned off). Nothing was paid. Turn a terminal on again in your panel, then create a new deposit for the customer. |
ip_not_allowed |
403 | This key may not be used from this IP address | The key has an IP allowlist (set per key in your panel: Developers, IP allowlist) and the call came from an address that is not on it; the detail names that address. Call from one of your listed servers, or add the address (or its range) to the key's list. A key with an empty list works from any IP. Test and live keys have their own lists. |
amount_outside_method_limits |
422 | The amount is outside your limits for this method | Each merchant has a smallest and a largest withdrawal amount, and a smallest and a largest deposit for each deposit method (in rials, set in your panel: Limits). Ask for an amount inside those limits, or split a larger withdrawal into several, or change the limits in the panel. The detail names the limits. |
withdrawal_not_found |
404 | Withdrawal not found | Check the withdrawal id. You can only see withdrawals of your own merchant account, and only in the mode of your key: a test key never finds a live withdrawal, a live key never a test one. |
withdrawal_not_open |
409 | The withdrawal is not open | The withdrawal already ended (paid, failed or cancelled) and cannot change any more, or it is on hold in your panel (release the hold first). Get it with GET /v1/withdrawals/{id} to see its status. |
card_transfer_failed |
none: a failure_code |
The card transfer could not be made | A card_to_card deposit's failure_code: the card transfer could not be made, and no money moved (the customer's card or details were refused, the payment could not start, or no answer came before the customer's dynamic password was sent). Create a new deposit if the customer wants to try again, with another card if the same one keeps failing. |
deposit_cancelled |
none: a failure_code |
The customer cancelled the payment | A deposit's failure_code: the customer pressed Cancel on the payment page before paying, so nothing was paid. Create a new deposit if the customer wants to pay after all. |
customer_has_recent_withdrawal |
409 | This customer has a recent withdrawal | One withdrawal per customer per day is turned on for your account (your panel: Limits). Wait until next_allowed_at, or cancel the earlier withdrawal in your panel (Withdrawals) if it should not be paid; a cancelled or failed withdrawal no longer counts. Withdrawals typed in your panel never count. |
11Idempotency and retries#
Send an Idempotency-Key (1 to 255 printable ASCII characters) with every POST. Your order id or a UUID is a
good key. Without one the request is refused with validation_failed.
-
Same key and same body: you get the same deposit (or withdrawal) back, with the header
Idempotent-Replayed: true, instead of a second one. -
Same key and another body (another amount,
referenceand so on): refused withidempotency_key_reused. Use a new key for a new request. -
Retrying is safe. After a timeout, a dropped connection or a
5xx, send the very same request again with the same key. Do not make up a new key for a retry, or you may create two deposits. - Keys and customers are separate in test and live mode.
12Withdrawals#
A withdrawal pays money out to your customer's own bank card and Sheba.
POST https://api.no1paytest.com/v1/withdrawals takes amount, currency,
customer.id, a destination with card_number, sheba and
holder_name, and its own webhook_url (required, like a deposit's: the only place that
withdrawal's webhooks go, and it never changes), and optionally your reference,
customer.level and destination.account_number (live keys only) (every field:
API reference). Send the card number and the Sheba only: we read
the bank from them. Both must have correct check digits. In rials, the amount must be inside the limits set on the
Limits page of your panel (amount_outside_method_limits otherwise).
| Field | Type | Required | What it is |
|---|---|---|---|
amount |
integer | Required | A whole number greater than 0, in the currency you send. In rials it must be inside the limits set in your panel. |
currency |
string | Required |
The currency of the amount: IRR is rials, IRT and TOM are tomans (1 toman is 10 rials).
One of
IRR,
TOM,
IRT.
|
customer.id |
string | Required | Your own id for the customer: the same customer as in your deposits. |
customer.level |
string | Optional | The name of one of your user levels, in any case, as for deposits. |
destination.card_number |
string | Required | The customer's 16-digit bank card number. Spaces, dashes and Persian digits are accepted; its check digit must be right. A test key takes only the test cards of the guide (step 8, Test data), for example 0000 0000 0000 0018; a live key refuses them. |
destination.sheba |
string | Required | The customer's Sheba: IR and 24 digits. Spaces and Persian digits are accepted; its check digits must be right. A test key takes only the test Shebas of the guide (step 8, Test data), for example IR650000000000000000000003; a live key refuses them. |
destination.holder_name |
string | Required | The name of the card and account holder, as the bank has it. |
destination.account_number |
string | Optional | The customer's bank account number, digits only. Live keys only: a test key refuses it. |
reference |
string | Optional | Your own id for this withdrawal, 1 to 64 printable ASCII characters. It comes back on the withdrawal and in every webhook. |
webhook_url |
string | Required | The address we send this withdrawal's webhooks to: the only place they go, and it never changes. Any public https URL, at most 2,048 characters. |
{
"amount": 25000000,
"currency": "IRR",
"customer": {
"id": "customer-77"
},
"destination": {
"card_number": "0000000000000018",
"sheba": "IR650000000000000000000003",
"holder_name": "Sara Ahmadi"
},
"reference": "payout-20931",
"webhook_url": "https://shop.example/webhooks/payments"
}
bash create-withdrawal.sh payout-20931 25000000 0000000000000018 IR650000000000000000000003
#!/usr/bin/env bash
# Create a withdrawal: pay money out to your customer's own card and Sheba.
#
# Usage: API_URL=https://api.example.com API_KEY=sk_test_... WEBHOOK_URL=https://your-server.example/webhooks ./create-withdrawal.sh <payout-id> <amount-in-rials> <card-number> <sheba>
# Example: ./create-withdrawal.sh payout-20931 25000000 0000000000000018 IR650000000000000000000003
#
# API_URL, API_KEY and WEBHOOK_URL come from the environment, never from this file.
# Use a payout id with letters, digits, "-" and "_" only. The bank is read from the card and the Sheba: do not send it.
set -euo pipefail
usage() {
echo "Usage: API_URL=... API_KEY=... WEBHOOK_URL=... $0 <payout-id> <amount-in-rials> <card-number> <sheba>" >&2
exit 2
}
[ "$#" -eq 4 ] || usage
[ -n "${API_URL:-}" ] && [ -n "${API_KEY:-}" ] && [ -n "${WEBHOOK_URL:-}" ] || usage
payout_id="$1"
amount="$2"
card_number="$3"
sheba="$4"
[[ "$payout_id" =~ ^[A-Za-z0-9_-]+$ ]] || usage
[[ "$amount" =~ ^[0-9]+$ ]] || usage
[[ "$card_number" =~ ^[0-9]+$ ]] || usage
[[ "$sheba" =~ ^[A-Za-z0-9]+$ ]] || usage
url_pattern='^https://[^"\\[:space:]]+$'
[[ "$WEBHOOK_URL" =~ $url_pattern ]] || usage
# webhook_url is where we send this withdrawal's webhooks: the only place, and it never changes.
body=$(printf '{"amount":%s,"currency":"IRR","customer":{"id":"customer-77"},"destination":{"card_number":"%s","sheba":"%s","holder_name":"Sara Ahmadi"},"reference":"%s","webhook_url":"%s"}' \
"$amount" "$card_number" "$sheba" "$payout_id" "$WEBHOOK_URL")
answer=$(mktemp)
trap 'rm -f "$answer"' EXIT
# The Idempotency-Key makes a retry safe: the same key and body return the same withdrawal, never a second payout.
status=$(curl --silent --show-error --output "$answer" --write-out '%{http_code}' \
--request POST "$API_URL/v1/withdrawals" \
--header "Authorization: Bearer $API_KEY" \
--header "Idempotency-Key: $payout_id" \
--header "Content-Type: application/json" \
--data "$body")
# Read one text member of an error answer (an error is JSON with "code", "detail" and "request_id").
field() {
sed -nE 's/.*"'"$1"'":"(([^"\\]|\\.)*)".*/\1/p' "$answer" | head -n 1 | sed -E 's/\\(.)/\1/g'
}
if [[ "$status" == 2* ]]; then
cat "$answer"
echo
else
echo "Error $(field code): $(field detail) (request_id $(field request_id))" >&2
exit 1
fi
php create-withdrawal.php payout-20931 25000000 0000000000000018 IR650000000000000000000003
<?php
// Create a withdrawal: pay money out to your customer's own card and Sheba.
//
// Usage: API_URL=https://api.example.com API_KEY=sk_test_... WEBHOOK_URL=https://your-server.example/webhooks php create-withdrawal.php <payout-id> <amount-in-rials> <card-number> <sheba>
// Example: php create-withdrawal.php payout-20931 25000000 0000000000000018 IR650000000000000000000003
//
// API_URL, API_KEY and WEBHOOK_URL come from the environment, never from this file. Needs PHP 8.4 with the curl extension.
// The bank is read from the card and the Sheba: do not send it.
if ($argc !== 5 || ! getenv('API_URL') || ! getenv('API_KEY') || ! getenv('WEBHOOK_URL')) {
fwrite(STDERR, "Usage: API_URL=... API_KEY=... WEBHOOK_URL=... php create-withdrawal.php <payout-id> <amount-in-rials> <card-number> <sheba>\n");
exit(2);
}
[, $payoutId, $amount, $cardNumber, $sheba] = $argv;
if (! ctype_digit($amount)) {
fwrite(STDERR, "The amount must be a whole number of rials.\n");
exit(2);
}
// webhook_url is where we send this withdrawal's webhooks: the only place, and it never changes.
$body = [
'amount' => (int) $amount,
'currency' => 'IRR',
'customer' => ['id' => 'customer-77'],
'destination' => [
'card_number' => $cardNumber,
'sheba' => $sheba,
'holder_name' => 'Sara Ahmadi',
],
'reference' => $payoutId,
'webhook_url' => getenv('WEBHOOK_URL'),
];
// The Idempotency-Key makes a retry safe: the same key and body return the same withdrawal, never a second payout.
$curl = curl_init(rtrim((string) getenv('API_URL'), '/').'/v1/withdrawals');
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($body),
CURLOPT_HTTPHEADER => [
'Authorization: Bearer '.getenv('API_KEY'),
'Idempotency-Key: '.$payoutId,
'Content-Type: application/json',
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
]);
$answer = curl_exec($curl);
if ($answer === false) {
fwrite(STDERR, 'Error connection_failed: '.curl_error($curl)." (request_id none)\n");
exit(1);
}
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
if ($status >= 200 && $status < 300) {
echo $answer, "\n";
exit(0);
}
// An error is JSON with "code", "detail" and "request_id".
$problem = json_decode($answer, true) ?: [];
fwrite(STDERR, sprintf("Error %s: %s (request_id %s)\n", $problem['code'] ?? 'unknown', $problem['detail'] ?? 'no detail', $problem['request_id'] ?? 'none'));
exit(1);
node create-withdrawal.mjs payout-20931 25000000 0000000000000018 IR650000000000000000000003
// Create a withdrawal: pay money out to your customer's own card and Sheba.
//
// Usage: API_URL=https://api.example.com API_KEY=sk_test_... WEBHOOK_URL=https://your-server.example/webhooks node create-withdrawal.mjs <payout-id> <amount-in-rials> <card-number> <sheba>
// Example: node create-withdrawal.mjs payout-20931 25000000 0000000000000018 IR650000000000000000000003
//
// API_URL, API_KEY and WEBHOOK_URL come from the environment, never from this file. Needs Node.js 24 (nothing to install).
// The bank is read from the card and the Sheba: do not send it.
const { API_URL, API_KEY, WEBHOOK_URL } = process.env;
const [payoutId, amount, cardNumber, sheba] = process.argv.slice(2);
if (process.argv.length !== 6 || !API_URL || !API_KEY || !WEBHOOK_URL || !/^\d+$/.test(amount)) {
console.error('Usage: API_URL=... API_KEY=... WEBHOOK_URL=... node create-withdrawal.mjs <payout-id> <amount-in-rials> <card-number> <sheba>');
process.exit(2);
}
// webhook_url is where we send this withdrawal's webhooks: the only place, and it never changes.
const body = {
amount: Number(amount),
currency: 'IRR',
customer: { id: 'customer-77' },
destination: { card_number: cardNumber, sheba, holder_name: 'Sara Ahmadi' },
reference: payoutId,
webhook_url: WEBHOOK_URL,
};
// The Idempotency-Key makes a retry safe: the same key and body return the same withdrawal, never a second payout.
let response;
try {
response = await fetch(`${API_URL.replace(/\/$/, '')}/v1/withdrawals`, {
method: 'POST',
headers: {
Authorization: `Bearer ${API_KEY}`,
'Idempotency-Key': payoutId,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(30_000),
});
} catch (error) {
console.error(`Error connection_failed: ${error.cause?.message ?? error.message} (request_id none)`);
process.exit(1);
}
const answer = await response.text();
if (response.ok) {
console.log(answer);
} else {
// An error is JSON with "code", "detail" and "request_id".
let problem = {};
try {
problem = JSON.parse(answer);
} catch {}
console.error(`Error ${problem.code ?? 'unknown'}: ${problem.detail ?? 'no detail'} (request_id ${problem.request_id ?? 'none'})`);
process.exit(1);
}
python3 create-withdrawal.py payout-20931 25000000 0000000000000018 IR650000000000000000000003
# Create a withdrawal: pay money out to your customer's own card and Sheba.
#
# Usage: API_URL=https://api.example.com API_KEY=sk_test_... WEBHOOK_URL=https://your-server.example/webhooks python3 create-withdrawal.py <payout-id> <amount-in-rials> <card-number> <sheba>
# Example: python3 create-withdrawal.py payout-20931 25000000 0000000000000018 IR650000000000000000000003
#
# API_URL, API_KEY and WEBHOOK_URL come from the environment, never from this file. Needs Python 3.9 (nothing to install).
# The bank is read from the card and the Sheba: do not send it.
import json
import os
import sys
import urllib.error
import urllib.request
api_url = os.environ.get("API_URL", "")
api_key = os.environ.get("API_KEY", "")
webhook_url = os.environ.get("WEBHOOK_URL", "")
if len(sys.argv) != 5 or not api_url or not api_key or not webhook_url or not sys.argv[2].isdigit():
print(
"Usage: API_URL=... API_KEY=... WEBHOOK_URL=... python3 create-withdrawal.py <payout-id> <amount-in-rials> <card-number> <sheba>",
file=sys.stderr,
)
sys.exit(2)
payout_id = sys.argv[1]
amount = int(sys.argv[2])
card_number = sys.argv[3]
sheba = sys.argv[4]
# webhook_url is where we send this withdrawal's webhooks: the only place, and it never changes.
body = {
"amount": amount,
"currency": "IRR",
"customer": {"id": "customer-77"},
"destination": {"card_number": card_number, "sheba": sheba, "holder_name": "Sara Ahmadi"},
"reference": payout_id,
"webhook_url": webhook_url,
}
# The Idempotency-Key makes a retry safe: the same key and body return the same withdrawal, never a second payout.
request = urllib.request.Request(
api_url.rstrip("/") + "/v1/withdrawals",
data=json.dumps(body).encode("utf-8"),
method="POST",
headers={
"Authorization": "Bearer " + api_key,
"Idempotency-Key": payout_id,
"Content-Type": "application/json",
},
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
print(response.read().decode("utf-8"))
except urllib.error.HTTPError as error:
# An error is JSON with "code", "detail" and "request_id".
try:
problem = json.loads(error.read().decode("utf-8"))
except ValueError:
problem = {}
print(
"Error %s: %s (request_id %s)"
% (problem.get("code", "unknown"), problem.get("detail", "no detail"), problem.get("request_id", "none")),
file=sys.stderr,
)
sys.exit(1)
except (urllib.error.URLError, OSError) as error:
print("Error connection_failed: %s (request_id none)" % error, file=sys.stderr)
sys.exit(1)
The answer is 201 with the withdrawal object:
201 The withdrawal, waiting for approval
{
"id": "wd_01k6c3s0v1m2n3p4q5r6s7t8v9",
"object": "withdrawal",
"mode": "test",
"status": "waiting_for_approval",
"method": null,
"amount": 25000000,
"currency": "IRR",
"paid_amount_rials": 0,
"reference": "payout-20931",
"customer": {
"id": "customer-77",
"level": "default"
},
"destination": {
"card_last4": "0018",
"sheba_last4": "0003",
"holder_name": "Sara Ahmadi",
"bank": null,
"card_number": "0000000000000018",
"sheba": "IR650000000000000000000003",
"account_number": null
},
"version": 1,
"failure_code": null,
"failure_message": null,
"webhook_url": "https://shop.example/webhooks/payments",
"created_at": "2026-10-03T09:30:00Z",
"updated_at": "2026-10-03T09:30:00Z",
"paid_at": null
}
-
The answer is
201with the withdrawal (wd_…,"status": "waiting_for_approval"). Your team approves it in your panel and picks how it is paid. With an automatic approval limit set in your panel, a withdrawal of at most that amount is approved at once ("status": "open"). -
The answer shows the card number and Sheba in full (
destination.card_number,destination.sheba, anddestination.account_numberwhen you sent one), next to their last 4 digits, the holder and the bank (a test card or test Sheba belongs to no bank, so the bank is null); a webhook carries only the last 4 digits, the holder and the bank. -
You get
withdrawal.paidwhen all of it was paid andwithdrawal.cancelledwhen your team cancelled it, signed and ordered bydata.versionlike deposit webhooks. A withdrawal can be paid in several parts:paid_amount_rialssays how much reached the customer so far, always in rials, also on a cancelled one;amountandcurrencystay exactly as you sent them. -
GET https://api.no1paytest.com/v1/withdrawals/{id}returns it again (withdrawal_not_foundfor an id of another mode or merchant). Your answer to the webhook never changes the withdrawal. -
In test mode a test key takes only a test card in
destination.card_numberand a test Sheba indestination.sheba, and the Sheba decides how the withdrawal ends: one pays it by itself, one fails it by itself and one leaves it for you to end. Each number, with its webhook, is in step 8; any other card or Sheba is refused. The Sheba of the sample above is the one that leaves it for you to end. -
In test mode, end a test withdrawal with
POST https://api.no1paytest.com/v1/withdrawals/{id}/simulateand{"outcome":"paid"}or{"outcome":"failed"}(the fieldoutcome: paid or failed); one still waiting for approval is approved first. A withdrawal that ended, or is on hold in your panel, answerswithdrawal_not_open. -
A customer blocked in your panel is refused with
customer_blocked, or withcustomer_method_blockedwhen blocked from this one method. -
One withdrawal per customer per day. If you turn it on (the Limits page of your panel), a withdrawal
for a customer who already has one made in the last 24 hours is refused with
customer_has_recent_withdrawal, andnext_allowed_atsays when the next one is possible. A withdrawal that was cancelled or failed no longer counts, and withdrawals typed in your panel are never refused and never counted.
409 A second withdrawal for the same customer within 24 hours
{
"type": "https://docs.no1paytest.com/errors/customer_has_recent_withdrawal",
"status": 409,
"code": "customer_has_recent_withdrawal",
"title": "This customer has a recent withdrawal",
"detail": "This customer already has a withdrawal made in the last 24 hours (wd_01k6c3s0v1m2n3p4q5r6s7t8v9). One withdrawal per customer per day is turned on for your account; the next one is possible from 2026-10-04T09:30:00Z.",
"next_allowed_at": "2026-10-04T09:30:00Z",
"request_id": "req_01k6c0z7x4m2p9q8r5s3t1v0w2"
}
13The IP allowlist#
You can limit each key to your own servers. On the Developers page of your panel, give the key an IP allowlist (one
address or range per line, for example 203.0.113.7 or 198.51.100.0/24). A call with that key
from any other address answers ip_not_allowed and names the address it came from, so you can add
it if it is yours. A key without a list works from any IP. Test and live keys have their own lists, and replacing a
key keeps its list.
14Going live#
Live mode is switched on for your account by us. Until then a live key answers live_mode_disabled
(test keys keep working). Once it is on, make a live key on the Developers page and put it in your server's
configuration in place of the test key. Nothing else in your code changes: the same API serves both modes and
mode tells them apart. Live webhooks are signed with your live webhook secret: make sure your server
verifies them with it (test webhooks keep the test secret).
Before you start taking real payments, check that you: