Payments Docs
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 https address on your server.
  • A language you know curl, PHP, Node.js or Python. Nothing to install.

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 in mode. 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. IRR is rials; IRT and TOM are tomans (1 toman = 10 rials). The examples use rials.
  • Every deposit and withdrawal carries its own webhook_url. It is required: any public https URL, 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).

Shell
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.

The flow of one payment Your server creates a deposit, sends the customer to the payment page, receives the signed webhook, confirms the deposit with a GET request and then delivers the order. 1 Your server → us POST /v1/deposits 2 Us → your server the deposit, with payment_url 3 Your server → customer send the browser to payment_url 4 Customer → payment page pays, then returns to redirect_url 5 Us → your server webhook deposit.paid, signed 6 Your server → us GET /v1/deposits/{id}: paid? deliver

Your server Customer's browser Us Each step below says who does it.

  1. Your server Creates the deposit with POST /v1/deposits: the amount and your order id. Create a deposit →
  2. Us Answers with the deposit: its id and a payment_url. Create a deposit →
  3. Your server Sends the customer's browser to payment_url. Send the customer to the payment page →
  4. 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 →
  5. Us Sends your server a signed webhook, deposit.paid. Receive and verify the webhook →
  6. Your server Checks the signature, confirms with GET /v1/deposits/{id}, and only then delivers the order. Confirm with a GET →

2Create a deposit#

Your server makes this call, when the customer presses "Pay" in your shop.

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.

FieldTypeRequiredWhat 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.
Request body (JSON)
{
  "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"
}
curlcreate-deposit.sh
Run it
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
PHPcreate-deposit.php
Run it
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.jscreate-deposit.mjs
Run it
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);
}
Pythoncreate-deposit.py
Run it
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
JSON
{
  "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).

JSON
{
  "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
JSON
{
  "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:

Shell
export DEPOSIT_ID=dep_…
Check it works

You should get 201, "status": "pending" and a payment_url. If you do not:

  • A wrong or missing key answers authentication_failed. Send Authorization: Bearer <key> with a test key.
  • A missing field, a missing webhook_url or a missing Idempotency-Key answers validation_failed; errors names each field.
  • A live key while live mode is off answers live_mode_disabled. Use your test key.

3Choose who picks the method#

Your server decides in the create call. Customer's browser picks, if you let it.

A deposit is paid with one of four methods. Which one is the question of this step:

MethodHow 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.

The two ways a deposit gets its method You send method when you create the deposit and the customer goes straight to that method's payment page. Or you leave method out: the customer lands on the method selection page, picks a method, can go back to pick again until money may move, and then pays on the method's payment page. Your server: POST /v1/deposits with method, or without A. You send method Customer → that method's page pending, method fixed B. You leave method out Customer → method selection page waiting_for_method, method null Customer picks, may go back pending, method set Customer pays on that page no way back once money may move
QuestionYou send methodYou 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).

Request body (JSON), no method
{
  "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):

Command, no method
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
JSON
{
  "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 pending with the picked method, 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 to waiting_for_method with method null, never failed, 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_method with method null 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, with method null 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.

Note

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):

Simulate request body (JSON), a deposit waiting for its method
{
  "outcome": "paid",
  "method": "pol_transfer"
}

4Send the customer to the payment page#

Customer's browser pays on our 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.

Warning

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#

Us call your server. Your server checks and answers.

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:

Webhook body, the exact text we send
{"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 headers
Webhook-Id: whd_01k6c3s0v1m2n3p4q5r6s7t8v9
Webhook-Timestamp: 1791019812
Webhook-Signature: v1,pWO5zzI19dESgKJVkhz0RflSn9eU+772kmTdfzahCM0=
Webhook-Event: deposit.paid
The same body, indented to read
Webhook body, indented
{
  "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"
}
Tip

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:

  1. Take the raw request body exactly as received, before any JSON parsing.
  2. Build the string <Webhook-Id>.<Webhook-Timestamp>.<raw body>.
  3. Compute HMAC-SHA256 of it with the webhook secret of its mode (test or live) as the key: the part after whsec_, base64-decoded. Base64-encode the result and put v1, in front.
  4. Compare it, in constant time, with each value in Webhook-Signature (they are separated by spaces). One must match.
  5. Refuse the event when its timestamp is more than 5 minutes from your clock.
curlverify-webhook.sh
Run it
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
PHPverify-webhook.php
Run it
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);
}
Node.jsverify-webhook.mjs
Run it
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);
}
Pythonverify-webhook.py
Run it
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#

Warning

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.

curlwebhook-once.sh
Run it
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
PHPwebhook-once.php
Run it
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";
}
Node.jswebhook-once.mjs
Run it
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');
}
Pythonwebhook-once.py
Run it
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 serverWhat 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 2xx quickly, 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 the id in the body; the same on every delivery of one event).
  • Events can arrive out of order. For one deposit, ignore an event whose data.version is 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 its mode, 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 same webhook_url.

6Confirm with a GET#

Your server asks us, before it delivers the order.

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.

curlget-deposit.sh
Run it
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
PHPget-deposit.php
Run it
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.jsget-deposit.mjs
Run it
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);
}
Pythonget-deposit.py
Run it
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
JSON
{
  "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"
}
Note

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#

Your server Customer's browser a full payment in test mode, with no real money.

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:

  1. Create a deposit with your test key (step 2) and keep its payment_url.
  2. Open payment_url in a browser. You see the test payment page.
  3. Press the paid button, or end the deposit from your server with the simulate call below. A card_to_card deposit (its amount inside 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 card 0000 0000 0000 0018, any expiry and CVV2, then the code 111111 at every code step (step 8).
  4. Your webhook URL receives a signed deposit.paid with "mode": "test". Verify it (step 5).
  5. Get the deposit (step 6): its status is paid and its version is 2.

To end the deposit from your server instead of pressing the button:

FieldTypeRequiredWhat 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.
Request body (JSON)
{
  "outcome": "paid"
}
curlsimulate-deposit.sh
Run it
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
PHPsimulate-deposit.php
Run it
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.jssimulate-deposit.mjs
Run it
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);
}
Pythonsimulate-deposit.py
Run it
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
JSON
{
  "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 sendThe deposit becomesWebhookfailure_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) is waiting_for_method until its method is picked. On the test payment page the customer picks it; from your server, send method with paid or failed: the simulator picks it first, then ends the deposit (version 3). expired needs no method, and a deposit that already has a method refuses one. A method that is not available for this deposit (not in its allowed_methods, turned off, blocked for the customer or outside its limits) answers method_unavailable.
  • A test deposit nobody ends expires at expires_at, like a live one. After that, paid and failed are 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 answers deposit_not_open.
  • Test webhooks are signed with your test webhook secret, never the live one, and go to the webhook_url you sent with that test deposit.

8Test data#

Your server Customer's browser made-up numbers with a fixed outcome, so every ending can be tested.

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 cardWhat happensSteps after the card formEnds 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.

CodeAt the login code and the bank page codeAt 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 ShebaWhat happens to the test withdrawalEnds 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 numberWhat happens to the depositEnds 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_expired and deposit.expired. The page's expired button and expired in 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_cancelled and deposit.cancelled.
  • Unknown never happens in test mode. A deposit is unknown only 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
JSON
{
  "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
JSON
{
  "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.

StatusWhat it meansCan 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:

StatusWhat it meansCan 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:

EventAboutWhen
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:

Error 422 (JSON)
{
  "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, never title or detail: the words may change, the codes do not.
  • errors is 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_amounts on method_unavailable, next_allowed_at on customer_has_recent_withdrawal).
  • Wait and try again on rate_limit_exceeded (you may make 600 calls a minute in each mode; the answer has a Retry-After header) and on a 5xx such as internal_error; fix your request on a 4xx.
Tip

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).

CodeHTTPWhat it meansWhat 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, reference and so on): refused with idempotency_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#

Your server pays money out to your customer.

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).

FieldTypeRequiredWhat 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.
Request body (JSON)
{
  "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"
}
curlcreate-withdrawal.sh
Run it
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
PHPcreate-withdrawal.php
Run it
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.jscreate-withdrawal.mjs
Run it
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);
}
Pythoncreate-withdrawal.py
Run it
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
JSON
{
  "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 201 with 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, and destination.account_number when 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.paid when all of it was paid and withdrawal.cancelled when your team cancelled it, signed and ordered by data.version like deposit webhooks. A withdrawal can be paid in several parts: paid_amount_rials says how much reached the customer so far, always in rials, also on a cancelled one; amount and currency stay exactly as you sent them.
  • GET https://api.no1paytest.com/v1/withdrawals/{id} returns it again (withdrawal_not_found for 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_number and a test Sheba in destination.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}/simulate and {"outcome":"paid"} or {"outcome":"failed"} (the field outcome: paid or failed); one still waiting for approval is approved first. A withdrawal that ended, or is on hold in your panel, answers withdrawal_not_open.
  • A customer blocked in your panel is refused with customer_blocked, or with customer_method_blocked when 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, and next_allowed_at says 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
JSON
{
  "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:

Security

Webhooks

Requests

Going live

15Next steps#