Quickstart
Take your first test payment.
Create an API client in the dashboard, exchange it for an access token on your server, then create a payment and read it back to see it succeed. Keep the API client secret on your server, never in a browser.
Sandbox only. These examples never move real money.
Get your keys
In the dashboard, open Developers → API clients in Sandbox and create an API client for your server that can create and read payments and read payment methods. Store its client ID and secret in your server's secret store: the secret is shown once. Copy the API base URL from the same page: it's shown above your clients. It is https://api.perionpayments.com for Sandbox and Live alike; the API client you use decides the environment.
Get an access token
# From Developers → API clients: the API base URL and your client ID
export PERION_API_BASE_URL="https://api.perionpayments.com"
export PERION_CLIENT_ID="cl_…"
export PERION_CLIENT_SECRET="…" # from your secret store, never from code
# Exchange them for a ten-minute access token (uses jq)
export PERION_ACCESS_TOKEN=$(curl -s -u "$PERION_CLIENT_ID:$PERION_CLIENT_SECRET" \
-d 'grant_type=client_credentials' \
-d 'scope=payment_intents:write payment_intents:read payment_methods:read' \
"$PERION_API_BASE_URL/oauth/token" | jq -r .access_token){
"access_token": "…",
"token_type": "Bearer",
"expires_in": 600,
"scope": "payment_intents:write payment_intents:read payment_methods:read"
}A token lasts ten minutes, so ask for a new one rather than storing it. A 400 invalid_scope means this API client was not given one of the scopes you asked for: ask for fewer, or create a client that has them.
Create the payment
curl "$PERION_API_BASE_URL/v1/payment_intents" \
-H "Authorization: Bearer $PERION_ACCESS_TOKEN" \
-H "Perion-Version: 2026-07-15" \
-H "Idempotency-Key: order-10428-create" \
-H "Content-Type: application/json" \
-d '{
"amount": 2500,
"currency": "USD",
"merchant_reference": "order_10428",
"payment_method": {
"type": "mobile_money",
"rail": "sandbox",
"mobile_number": "+263770000001"
},
"confirm": true,
"return_url": "https://merchant.example/orders/10428"
}'{
"id": "pi_01K0A9N3ZQ2X7V4B8C6D5E1F0G",
"object": "payment_intent",
"livemode": false,
"amount": 2500,
"currency": "USD",
"status": "requires_action",
"next_action": {
"type": "approve_on_device",
"display_message": "Approve the payment prompt on your phone.",
"mobile_number_masked": "********0001"
},
"next_poll_after": "2026-10-14T09:30:02Z",
…
}requires_action with approve_on_device means the payer must approve the prompt on their phone. Keep the id: you read the same payment next, and you never create another one for this order.
Check it succeeded
export PERION_PAYMENT_ID="pi_…" # the id from step 3
curl "$PERION_API_BASE_URL/v1/payment_intents/$PERION_PAYMENT_ID" \
-H "Authorization: Bearer $PERION_ACCESS_TOKEN" \
-H "Perion-Version: 2026-07-15"{
"id": "pi_01K0A9N3ZQ2X7V4B8C6D5E1F0G",
"amount": 2500,
"currency": "USD",
"status": "succeeded",
"next_action": null,
"succeeded_at": "2026-10-14T09:30:02Z",
…
}The test number +263 77 000 0001 approves about two seconds after you create the payment, so this read shows succeeded. On a real payment, read no sooner than next_poll_after.
Fulfill only on payment_intent.succeeded or a server-side read.
Fulfill the order once, when a signed payment_intent.succeeded event arrives or a read like this one shows succeeded for the expected amount and currency. A browser return or a success screen does not prove payment.
Try a failure
Repeat step 3 with +263770000002 and a new Idempotency-Key, such as order-10429-create. The payment ends failed with failure_code: insufficient_funds. Reusing the old key with a changed body is refused with 409 idempotency_key_reused. Every test number is listed in Sandbox scenarios.
Get events
Add a webhook endpoint so your server hears about each result without polling: set up your endpoint. When you're ready to build for production, follow the Recommended integration.