API reference

One API to buy codes straight from your own shop. A request carries the SKU and the quantity only — the price is worked out on our server from your account's agreed price, and is never read out of the request whatever is sent in it.

Base address

https://mena-pin.com/v1

Getting started, in three steps

  1. 01

    Get an account and a key

    From your dashboard → API keys. The key is shown once, at the moment it is issued, so keep it somewhere safe.

  2. 02

    Fetch the catalogue

    Browse the denominations on offer and your own price for each, then store the SKUs of the ones you sell.

  3. 03

    Place an order

    Send the SKU and the quantity with an idempotency header. Your wallet is charged and the order comes back pending until the code is issued.

Your first request

curl https://mena-pin.com/v1/balance \
  -H "Authorization: Bearer mp_live_YOUR_KEY"

Authentication

Every request carries your key in the Authorization header. Keys begin with mp_live_, and every one of them spends your real balance and delivers real codes — there is no separate test environment.

The header

Authorization: Bearer mp_live_YOUR_KEY

We keep only a fingerprint of the key and never the key itself — which is why it cannot be recovered once it has been issued. If your key leaks, issue a new one from the dashboard: the previous one stops working at once.

No double buying

Networks drop, a customer clicks twice, and jobs are retried. Send an Idempotency-Key header of your own, with a value unique to you, on every create request. Sending it again with the same value hands you back the very same first order — not a second order, not a second charge, and not a second code.

The same key twice = one order

Idempotency-Key: order-invoice-84213

How amounts are represented

Every amount comes back as an object with three fields. Use minor_units in your arithmetic — a whole number of the currency's smallest unit — and show amount to the user. Never compute on the decimal value.

{
  "amount": "0.83",
  "minor_units": 83,
  "currency": "USD"
}

Rate limits

The ceiling is counted per key, and the X-RateLimit-Limit and X-RateLimit-Remaining headers come back with every response. Going over it answers 429.

The default ceiling is 60 requests a minute per key. If a higher one has been agreed for your account then that is the one that applies, and it appears in X-RateLimit-Limit on your first response.

The endpoints

GET /v1/catalog

The denominations on offer, at your own price, split over pages, with per_page adjustable up to 100.

The request

curl "https://mena-pin.com/v1/catalog?per_page=25" \
  -H "Authorization: Bearer mp_live_YOUR_KEY"

The response

{
  "data": [
    {
      "slug": "pubg-mobile",
      "name": "ببجي موبايل",
      "category": "games",
      "region": "global",
      "packages": [
        {
          "sku": "PUBG_MOBILE_60_UC",
          "name": "60 UC",
          "pricing": "per_item",
          "pricing_unit": 1,
          "price":      { "amount": "0.83", "minor_units": 83, "currency": "USD" },
          "list_price": { "amount": "0.89", "minor_units": 89, "currency": "USD" }
        }
      ]
    }
  ],
  "meta": { "current_page": 1, "per_page": 25, "total": 12 }
}

price is what you will pay, and list_price is the list price before your account's discount. The difference between the two is your profit before you have sold anything.

GET /v1/balance

Your wallet balance as it stands — and what every order you place comes out of.

The response

{
  "data": {
    "balance": { "amount": "500.00", "minor_units": 50000, "currency": "USD" },
    "currency": "USD"
  }
}

Two different kinds: per_item and per_unit.

An ordinary denomination is sold by the item: price is the price of one and quantity is how many.

A per_unit denomination is sold by the counter — coins, minutes, credit — and one unit can be worth a fraction of a cent, which is less than the smallest amount that can be written down. So price is quoted per pricing_unit units, while quantity in an order means the number of units rather than the number of blocks.

To price any quantity, use price_per_unit — the exact price of one unit, as a decimal string. The quantity itself is bounded by min_quantity, max_quantity and quantity_step; anything outside them is refused with 422 invalid_quantity and no order is created.

And the response carries no count of what is available: a denomination listed here can be ordered, and one that cannot be ordered is not listed at all — there is no shelf to count and none to run empty.

POST /v1/orders

Creates an order. The body carries the SKU and the quantity, and player_id for the denominations that are credited straight to an account.

The request

curl -X POST https://mena-pin.com/v1/orders \
  -H "Authorization: Bearer mp_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-invoice-84213" \
  -d '{"sku":"PUBG_MOBILE_60_UC","quantity":1,"player_id":"5512345"}'

The response · 201

{
  "data": {
    "number": "01kzhxexgmfqka0q1zmtr0f292",
    "status": "pending",
    "quantity": 1,
    "pricing": "per_item",
    "pricing_unit": 1,
    "unit_price": { "amount": "0.83", "minor_units": 83, "currency": "USD" },
    "total":      { "amount": "0.83", "minor_units": 83, "currency": "USD" },
    "discount_basis_points": 650,
    "package": {
      "sku": "PUBG_MOBILE_60_UC",
      "name": "60 UC",
      "product": { "slug": "pubg-mobile", "name": "ببجي موبايل" }
    },
    "placed_by": { "name": "سامر", "email": "samer@example.com" },
    "delivered_at": null,
    "created_at":   "2026-08-09T00:06:16+00:00",
    "codes": []
  }
}

An order always comes back pending.

An order is accepted before it is filled, so nothing is delivered inside the response itself and your connection is never held open: your wallet is charged and the order comes back pending with an empty codes list. Fulfilment is automatic and takes seconds: order.delivered reaches you on your webhook when it completes — and the code is read from GET /v1/orders/{number} — or order.refunded if it could not be filled and the whole amount was returned. You are never charged for an order that was not delivered.

The quantity for these denominations means the number of units (the number of coins, say), and it has a minimum, a maximum and a step. A quantity outside them is refused with 422 invalid_quantity and no order is created.

player_id is required for the denominations credited straight to an account — those are the ones added to an account inside a game or a service rather than handed over as a code. An order without it is refused with 422 player_id_required before anything is charged, and no order is created. Check the value before you send it: what has reached an account cannot be taken back out of it, and no refund makes up for it.

And if an order stays pending longer than usual.

Then it is under review, and nothing about it is decided by guessing: no code is delivered, no money is returned, and the order is never sent again. Its result reaches you on your webhook when it is settled — order.delivered or order.refunded — and you can read it at GET /v1/orders/{number} at any time. You will not be charged twice for it and it will never issue two codes, however long it waits.

So do not send the order again to hurry it along. Sending it again with the same Idempotency-Key returns the first order exactly as it stands, and sending it with a new one creates a second order and charges for it.

Do not send a price.

Any price field in the body is ignored outright. The price is worked out on the server from your account's price at the moment of the order, so neither your customer — nor you by accident — can place an order at a price that is not yours.

GET /v1/orders/{number}

One order, with its codes. An order that is not on your account answers 404 rather than 403 — we do not confirm that somebody else's order numbers exist.

The request

curl https://mena-pin.com/v1/orders/01kzhxexgmfqka0q1zmtr0f292 \
  -H "Authorization: Bearer mp_live_YOUR_KEY"
GET /v1/orders

Your orders, newest first, split over pages. This list never carries the codes — read a single order to get them.

The request

curl "https://mena-pin.com/v1/orders?per_page=50" \
  -H "Authorization: Bearer mp_live_YOUR_KEY"

Refunds

Refunds are fully automatic: there is no endpoint for one and no request to be made for one. An order that cannot be filled has its whole value returned to your wallet by the system itself, its status becomes refunded, the order.refunded event reaches you on your webhook if you are subscribed to it, and the movement appears in your wallet statement as a single entry with its date and the order number. So nothing stays debited for an order that was not delivered, and you are never asked to claim it.

And there is no refund on request.

What has been delivered is not returnable: a code that has been issued stays usable whatever the order says on our side, and credit that has reached an account cannot be pulled back out of it. So there is no button and no route for asking for a refund, in the API or in the dashboard, and the one state in which handing the money back is unambiguously right — fulfilment failing — is the state in which the system returns it by itself.

An order whose result has not been settled yet is neither refunded nor filled again, as POST /v1/orders above says: it is settled by an answer, and then order.delivered or order.refunded reaches you. You will not be charged twice for it in any case.

Webhooks

Instead of you asking the API over and over for news, we send a signed POST request to an address on your own server whenever something happens on your account. The addresses are managed from the dashboard, and each of them has a signing key of its own.

Event When it is sent
order.delivered Sent when the codes are delivered. It never carries the codes themselves.
order.refunded Sent when the value of an order is returned to your wallet.
wallet.credited Sent when your wallet is credited after a top-up has been confirmed.

The shape of the payload

id is the same across every attempt to deliver one event — store it and ignore any event you have already handled. data carries the order in exactly the shape the API returns it in.

The body that reaches you

{
  "id": "01K2H8QF3M4N5P6Q7R8S9T0V1W",
  "event": "order.delivered",
  "created_at": "2026-08-10T09:14:22+00:00",
  "data": {
    "number": "01K2H8Q...",
    "status": "delivered",
    "quantity": 1,
    "total": { "amount": "9.00", "minor_units": 900, "currency": "USD" },
    "package": { "sku": "PUBG-660", "name": "660 UC" },
    "delivered_at": "2026-08-10T09:14:22+00:00"
  }
}

A code is never sent in a notification. The notification goes to an address you chose and passes through whatever proxies and logs stand between us. A code is the goods themselves, so it is read from GET /v1/orders/{number} over a connection authenticated with your own key.

Verifying the signature

Every request carries an X-MenaPin-Signature header, shaped t=<unix>,v1=<hex>. The signed value is "{t}.{raw_body}", with HMAC-SHA256 and your endpoint's key. Verify the raw body before any parsing or reordering, and reject a timestamp older than 300 seconds.

The old prefix is still sent. The product carried a different name for a while, and during it the four fields arrived under the X-Doabletech- prefix. Both prefixes are sent together with exactly the same values — nothing changed on the wire — so whatever you wrote in either period still works as it stands. X-MenaPin- is the current one; the other is deprecated and will be removed later — move your verification across in your own time.

PHP

$payload   = file_get_contents('php://input');
$header    = $_SERVER['HTTP_X_MENAPIN_SIGNATURE'] ?? '';
$secret    = getenv('WEBHOOK_SECRET');

parse_str(strtr($header, ',', '&'), $parts);
$timestamp = (int) ($parts['t'] ?? 0);
$expected  = hash_hmac('sha256', $timestamp . '.' . $payload, $secret);

if (! hash_equals($expected, $parts['v1'] ?? '')) {
    http_response_code(400);   // bad signature
    exit;
}

if (abs(time() - $timestamp) > 300) {
    http_response_code(400);   // too old
    exit;
}

$event = json_decode($payload, true);
// handle $event['id'] exactly once, then:
http_response_code(200);

Answering, and retries

  • Answer 2xx the moment it reaches you, and defer any long processing. Our timeout is 10 seconds.
  • On failure we retry up to 5 times, 10 seconds, 1 minute, 5 minutes, 30 minutes apart.
  • A 4xx answer means a deliberate refusal, so we do not repeat it — except for 408 and 429.
  • After 15 consecutive failures the endpoint is switched off and we send you an alert. Missed events are not replayed automatically once it is switched back on.
  • The address must be https and on a public network — we do not send to an internal address.

Errors

Every error comes back in the same shape, with a stable code in code that your program can branch on, and an Arabic message fit to be shown.

{
  "error": {
    "code": "insufficient_balance",
    "message": "الرصيد لا يكفي لإتمام هذه العملية."
  }
}
Status Code When it happens
401 unauthenticated The key is missing, unknown or revoked.
403 account_suspended The account has been suspended by us. Nothing new can be done with it — no order, and no key or phone works — until it is reinstated; orders already placed are still delivered or refunded.
402 insufficient_balance The wallet balance does not cover the order. Nothing was charged.
403 email_unverified The account's email address has not been confirmed yet, and no money moves before it is.
403 forbidden The key is valid but does not hold this ability — check the key's abilities in the dashboard.
404 not_found An unknown SKU, or an order number that is not on your account.
409 package_unavailable The denomination is off sale, or cannot be filled at the moment. Nothing was charged.
409 idempotency_conflict An idempotency key already used for a different order (another denomination, or another quantity).
422 validation_failed The body is incomplete or invalid — the details are in fields.
422 invalid_quantity The quantity is outside the denomination's minimum, maximum or step. No order was created.
422 player_id_required The denomination is credited to an account and no player_id arrived. No order was created and nothing was charged.
429 rate_limited You have gone over your account's ceiling of requests a minute.

Alongside those, 400 bad_request can come back for a body that is structurally invalid, and 405 method_not_allowed for a method the path does not accept. Both are mistakes in how the request was built rather than anything about the state of your account.

Nothing stays debited for an order that was not delivered. The debit happens together with the creation of the order, in one operation under a lock, and if the order cannot be filled its whole value is returned to your wallet and the return is written into the statement as a single movement.

Need a key?

Open an account, confirm your email address, and issue your key from the dashboard. There is no test mode: a key places real orders from your own balance.

Open an account