Everything in this guide runs with a test key. Test mode is a full copy of the API in which nobody pays and no bank answers, so you decide how each payment ends: you make it succeed, fail or expire, and your integration meets every outcome before a real customer can cause one.
Test keys and live keys
Section titled “Test keys and live keys”An API key is a client ID and a client secret, and it belongs to one mode (see Authentication for how a key is exchanged for an Access Token).
| Test | Live | |
|---|---|---|
| Client ID | ci_test_... | ci_live_... |
| Client secret | sk_test_... | sk_live_... |
| Access Token | at_test_... | at_live_... |
| Payments | Simulated. No money moves. | Real. |
| Available | From sign-up | Once your account is activated |
You can make test keys in the Merchant Dashboard, under Developers → API keys, from the moment you sign up: you do not have to wait for your business to be verified. Live keys are offered once your account is activated; see Going live. A key sees only its own mode's objects (a test intent asked for with a live key is not_found), and every object says which mode it is in with livemode.
The examples below call the API with an Access Token. Get one as in Authentication, then keep it for the commands:
curl https://api.example.com/oauth/token \ -u "ci_test_...:sk_test_..." \ -d grant_type=client_credentials{"access_token": "at_test_...", "token_type": "Bearer", "expires_in": 900}export ACCESS_TOKEN=at_test_...What test mode does, and never does
Section titled “What test mode does, and never does”- No money moves. A test payment is only ever simulated: nobody is charged, nothing is paid out, and no fee is taken. A refund of one returns nothing.
- Only simulated payment methods. A test payment never reaches a real bank or card network. A QR Ph code shown in test mode cannot be paid by a banking app, a test cash reference cannot be paid at a counter, and a card page asks for no card number. A method that cannot be simulated is refused in test mode with
test_mode_unavailable. - Test payments are marked Test. Their
livemodeisfalse, in the API and in the events and webhooks they send, and the Merchant Dashboard marks them Test in Transactions. - It does not wait on your account. A test payment works before your account is activated, whichever methods are enabled and whatever prices are set, so you can build before any of those is settled. A method an operator has suspended is refused in test mode too, and so is a rejected, suspended or closed account, as in live. Cash is the exception: a test cash payment needs your account to be able to take cash, as a live one does (see Accepting cash), and until it can, confirming with cash is refused with a
policy_rejection. - It behaves like the real thing otherwise. The same statuses, the same
last_payment_errorshapes, the same events and receipts. Only the outcome is yours to choose.
A simulated payment does not decide anything on its own. Left alone, a test QR Ph payment waits for a payment that never comes until its code runs out, a test cash reference waits for cash nobody brings until it expires, and a test card payment waits on the simulated card page. The rest of this guide is how to give each its outcome.
Create a payment intent with the method named, and confirm it in the same call. The answer is the intent waiting for a customer, with the code to show in next_action:
curl https://api.example.com/v1/payment_intents \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Idempotency-Key: testing-qrph-1" \ -H "Content-Type: application/json" \ -d '{ "amount": 10000, "currency": "php", "description": "Test order 1", "payment_method_data": {"type": "qrph"}, "confirm": true }'{ "id": "pi_01j9x2m8k4q7r5t3v6w8y0z2a4", "object": "payment_intent", "amount": 10000, "amount_received": 0, "currency": "php", "payment_method_types": ["qrph"], "channel": "ecommerce", "status": "requires_action", "next_action": { "type": "qrph_display_qr_code", "qrph_display_qr_code": {"data": "00020101021228...", "expires_at": 1790412300} }, "last_payment_error": null, "cancellation_reason": null, "canceled_at": null, "description": "Test order 1", "metadata": {}, "livemode": false, "created": 1790411400}Keep its id for the commands that follow:
export PAYMENT_INTENT=pi_01j9x2m8k4q7r5t3v6w8y0z2a4From checkout
Section titled “From checkout”If your customer pays on our hosted checkout page, a test payment shows the code with a Test mode: no money moves banner under it, and two buttons:
- Simulate payment pays the code. The page follows as it follows a real payment: it shows the payment as paid and sends the customer back to your
return_url. - Simulate expiry runs the code out. The page says the code has expired and offers a new one.
A live checkout never shows the banner or the buttons.
From the test-helper API
Section titled “From the test-helper API”To do the same from your own code or test suite, call the test helper with the intent's id and the outcome. paid pays the code:
curl https://api.example.com/v1/test_helpers/payment_intents/$PAYMENT_INTENT/simulate \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Idempotency-Key: testing-qrph-1-paid" \ -H "Content-Type: application/json" \ -d '{"outcome": "paid"}'The answer is the intent as it then is. It is succeeded, with amount_received equal to amount, and payment_intent.succeeded is sent to your webhook endpoints as it is for a real payment:
{ "id": "pi_01j9x2m8k4q7r5t3v6w8y0z2a4", "object": "payment_intent", "amount": 10000, "amount_received": 10000, "currency": "php", "payment_method_types": ["qrph"], "channel": "ecommerce", "status": "succeeded", "next_action": null, "last_payment_error": null, "cancellation_reason": null, "canceled_at": null, "description": "Test order 1", "metadata": {}, "livemode": false, "created": 1790411400}To see a code run out instead, make another intent, with a key of its own:
curl https://api.example.com/v1/payment_intents \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Idempotency-Key: testing-qrph-2" \ -H "Content-Type: application/json" \ -d '{ "amount": 10000, "currency": "php", "description": "Test order 2", "payment_method_data": {"type": "qrph"}, "confirm": true }'{ "id": "pi_01j9x2n3b5d7f9h1k3m5p7r9t1", "object": "payment_intent", "amount": 10000, "amount_received": 0, "currency": "php", "payment_method_types": ["qrph"], "channel": "ecommerce", "status": "requires_action", "next_action": { "type": "qrph_display_qr_code", "qrph_display_qr_code": {"data": "00020101021228...", "expires_at": 1790412400} }, "last_payment_error": null, "cancellation_reason": null, "canceled_at": null, "description": "Test order 2", "metadata": {}, "livemode": false, "created": 1790411500}export PAYMENT_INTENT=pi_01j9x2n3b5d7f9h1k3m5p7r9t1Then send expired:
curl https://api.example.com/v1/test_helpers/payment_intents/$PAYMENT_INTENT/simulate \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Idempotency-Key: testing-qrph-2-expired" \ -H "Content-Type: application/json" \ -d '{"outcome": "expired"}'The intent goes back to requires_payment_method, with last_payment_error saying the customer did not pay in time, and payment_intent.payment_failed is sent. The code can no longer be paid; confirm the intent again for a new one.
{ "id": "pi_01j9x2n3b5d7f9h1k3m5p7r9t1", "object": "payment_intent", "amount": 10000, "amount_received": 0, "currency": "php", "payment_method_types": ["qrph"], "channel": "ecommerce", "status": "requires_payment_method", "next_action": null, "last_payment_error": { "code": "payment_intent_payment_attempt_expired", "message": "The customer did not pay before it expired." }, "cancellation_reason": null, "canceled_at": null, "description": "Test order 2", "metadata": {}, "livemode": false, "created": 1790411500}A code that has already run out its time when you ask for paid is expired, as it would be at the customer's bank.
Create and confirm a cash payment. The answer is the intent waiting for the customer's cash, with the reference to give them in next_action:
curl https://api.example.com/v1/payment_intents \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Idempotency-Key: testing-cash-1" \ -H "Content-Type: application/json" \ -d '{ "amount": 10000, "currency": "php", "description": "Test order 3", "payment_method_data": {"type": "cash"}, "confirm": true }'{ "id": "pi_01j9x2r2c4e6g8j0l2n4q6s8u0", "object": "payment_intent", "amount": 10000, "amount_received": 0, "currency": "php", "payment_method_types": ["cash"], "channel": "ecommerce", "status": "requires_action", "next_action": { "type": "cash_display_reference", "cash_display_reference": { "reference": "CP-V4UD-S2WG-9H4A", "amount": 10000, "currency": "php", "expires_at": 1790584200, "instructions": "Give this reference to the cashier at a cash collection location and pay the exact amount in cash." } }, "last_payment_error": null, "cancellation_reason": null, "canceled_at": null, "description": "Test order 3", "metadata": {}, "livemode": false, "created": 1790411400}export PAYMENT_INTENT=pi_01j9x2r2c4e6g8j0l2n4q6s8u0Nobody can pay a test reference at a counter, so say what the cash collection network would have: paid is the customer paying the exact amount at a location.
curl https://api.example.com/v1/test_helpers/payment_intents/$PAYMENT_INTENT/simulate \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Idempotency-Key: testing-cash-1-paid" \ -H "Content-Type: application/json" \ -d '{"outcome": "paid"}'The intent goes through processing to succeeded, as when a real customer pays at a counter, and payment_intent.processing then payment_intent.succeeded are sent:
{ "id": "pi_01j9x2r2c4e6g8j0l2n4q6s8u0", "object": "payment_intent", "amount": 10000, "amount_received": 10000, "currency": "php", "payment_method_types": ["cash"], "channel": "ecommerce", "status": "succeeded", "next_action": null, "last_payment_error": null, "cancellation_reason": null, "canceled_at": null, "description": "Test order 3", "metadata": {}, "livemode": false, "created": 1790411400}To see a reference expire instead, make another cash payment:
curl https://api.example.com/v1/payment_intents \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Idempotency-Key: testing-cash-2" \ -H "Content-Type: application/json" \ -d '{ "amount": 10000, "currency": "php", "description": "Test order 4", "payment_method_data": {"type": "cash"}, "confirm": true }'{ "id": "pi_01j9x2r5f7h9k1m3p5r7t9v1x3", "object": "payment_intent", "amount": 10000, "amount_received": 0, "currency": "php", "payment_method_types": ["cash"], "channel": "ecommerce", "status": "requires_action", "next_action": { "type": "cash_display_reference", "cash_display_reference": { "reference": "CP-ZKBM-YHX4-QXKG", "amount": 10000, "currency": "php", "expires_at": 1790584300, "instructions": "Give this reference to the cashier at a cash collection location and pay the exact amount in cash." } }, "last_payment_error": null, "cancellation_reason": null, "canceled_at": null, "description": "Test order 4", "metadata": {}, "livemode": false, "created": 1790411500}export PAYMENT_INTENT=pi_01j9x2r5f7h9k1m3p5r7t9v1x3Then send expired:
curl https://api.example.com/v1/test_helpers/payment_intents/$PAYMENT_INTENT/simulate \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Idempotency-Key: testing-cash-2-expired" \ -H "Content-Type: application/json" \ -d '{"outcome": "expired"}'The attempt fails as an unpaid reference's does: the intent is back in requires_payment_method, with last_payment_error saying the customer did not pay in time, and payment_intent.payment_failed is sent. The reference can no longer be paid; confirm the intent again for a new one.
{ "id": "pi_01j9x2r5f7h9k1m3p5r7t9v1x3", "object": "payment_intent", "amount": 10000, "amount_received": 0, "currency": "php", "payment_method_types": ["card", "cash", "qrph"], "channel": "ecommerce", "status": "requires_payment_method", "next_action": null, "last_payment_error": { "code": "payment_intent_payment_attempt_expired", "message": "The customer did not pay before it expired." }, "cancellation_reason": null, "canceled_at": null, "description": "Test order 4", "metadata": {}, "livemode": false, "created": 1790411500}A reference that has already run out its time when you ask for paid is expired, as it would be at the counter. Cash reaches your balance only once its deposit is confirmed, which never happens to test cash, so a test cash payment, like every test payment, moves no money.
Create and confirm a card payment. A card sends the customer away, so name a return_url:
curl https://api.example.com/v1/payment_intents \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Idempotency-Key: testing-card-1" \ -H "Content-Type: application/json" \ -d '{ "amount": 10000, "currency": "php", "payment_method_data": {"type": "card"}, "return_url": "https://shop.example.com/orders/1/paid", "confirm": true }'In test mode the customer is sent to the simulated card page on our checkout host, not to a page where a card is entered. next_action.redirect_to_url.url is its address:
{ "id": "pi_01j9x2p4d6f8h0k2m4n6q8s0t2", "object": "payment_intent", "amount": 10000, "amount_received": 0, "currency": "php", "payment_method_types": ["card"], "channel": "ecommerce", "status": "requires_action", "next_action": { "type": "redirect_to_url", "redirect_to_url": { "url": "https://checkout.example.com/pay/test-card/card_01j9x2p5r7t9v1x3z5b7d9f1h3jKxM4wQ8nVc2LpYtR6sHaE0uBgZ5dN-iF7oC", "return_url": "https://shop.example.com/orders/1/paid" } }, "last_payment_error": null, "cancellation_reason": null, "canceled_at": null, "description": null, "metadata": {}, "livemode": false, "created": 1790411600}export PAYMENT_INTENT=pi_01j9x2p4d6f8h0k2m4n6q8s0t2On the simulated card page
Section titled “On the simulated card page”The page is marked as test mode, asks for no card number, expiry or security code, and charges nothing. The same step is shown in place of card entry on our hosted checkout page and inside embedded checkout. It offers four choices, each ending as its live counterpart does:
| Choose | What happens | The intent |
|---|---|---|
| Pay | The bank approves it at once, with no check. | succeeded |
| Decline | The bank declines it. Choose a reason, Insufficient funds, Do not honor or Expired card, or none. | requires_payment_method, with last_payment_error.code card_declined (expired_card for that reason) |
| Challenge, then pay | The whole page goes to a simulated check by the customer's bank; select Finish the check and it passes. | succeeded |
| Challenge, then fail | The same check, which fails: the customer could not confirm it was them. | requires_payment_method, with last_payment_error.code payment_intent_authentication_failure |
Choose one and select Continue, or Continue to the bank’s check for a challenge. However it went, the customer is then sent back to your return_url with payment_intent added, as after a real card payment. Inside embedded checkout, a challenge leaves your page through a link the customer follows, and comes back to your return_url, as a live one does.
Coming back to your return_url proves nothing about the payment. Read the outcome from the intent or a webhook, as you do for a real one (see Payment intents). A page opened again after its outcome was chosen says the payment is already finished.
From the test-helper API
Section titled “From the test-helper API”The same outcomes are available to your code, so a test suite need not drive a page: approved is Pay, declined is Decline, three_d_secure_passed is Challenge, then pay and three_d_secure_failed is Challenge, then fail. The helper also has abandoned, the customer going back without paying, which the page does not offer. Send one, with an optional decline_reason for declined:
curl https://api.example.com/v1/test_helpers/payment_intents/$PAYMENT_INTENT/simulate \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Idempotency-Key: testing-card-1-declined" \ -H "Content-Type: application/json" \ -d '{"outcome": "declined", "decline_reason": "insufficient_funds"}'A failed card payment is the intent back in requires_payment_method, with last_payment_error saying why, and payment_intent.payment_failed is sent. It can be confirmed again, as after a real decline:
{ "id": "pi_01j9x2p4d6f8h0k2m4n6q8s0t2", "object": "payment_intent", "amount": 10000, "amount_received": 0, "currency": "php", "payment_method_types": ["card"], "channel": "ecommerce", "status": "requires_payment_method", "next_action": null, "last_payment_error": { "code": "card_declined", "decline_code": "insufficient_funds", "message": "The card has insufficient funds." }, "cancellation_reason": null, "canceled_at": null, "description": null, "metadata": {}, "livemode": false, "created": 1790411600}Each outcome leaves the intent like this:
outcome | decline_reason | The intent | last_payment_error.code |
|---|---|---|---|
approved | succeeded | ||
three_d_secure_passed | succeeded | ||
declined | none | requires_payment_method | card_declined |
declined | insufficient_funds | requires_payment_method | card_declined, with decline_code insufficient_funds |
declined | do_not_honor | requires_payment_method | card_declined, with decline_code do_not_honor |
declined | expired_card | requires_payment_method | expired_card, with decline_code expired_card |
three_d_secure_failed | requires_payment_method | payment_intent_authentication_failure | |
abandoned | requires_payment_method | payment_intent_payment_attempt_failed |
Refund a test card payment
Section titled “Refund a test card payment”A payment has to have succeeded to be refunded: the declined one above cannot be. Make another card payment as above, approve it, and refund it:
curl https://api.example.com/v1/payment_intents \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Idempotency-Key: testing-card-2" \ -H "Content-Type: application/json" \ -d '{ "amount": 10000, "currency": "php", "payment_method_data": {"type": "card"}, "return_url": "https://shop.example.com/orders/2/paid", "confirm": true }'{ "id": "pi_01j9x2q7s9u1w3y5a7c9e1g3i5", "object": "payment_intent", "amount": 10000, "amount_received": 0, "currency": "php", "payment_method_types": ["card"], "channel": "ecommerce", "status": "requires_action", "next_action": { "type": "redirect_to_url", "redirect_to_url": { "url": "https://checkout.example.com/pay/test-card/card_01j9x2q7w1y3a5c7e9g1i3k5m7", "return_url": "https://shop.example.com/orders/2/paid" } }, "last_payment_error": null, "cancellation_reason": null, "canceled_at": null, "description": null, "metadata": {}, "livemode": false, "created": 1790411650}export PAYMENT_INTENT=pi_01j9x2q7s9u1w3y5a7c9e1g3i5curl https://api.example.com/v1/test_helpers/payment_intents/$PAYMENT_INTENT/simulate \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Idempotency-Key: testing-card-2-approved" \ -H "Content-Type: application/json" \ -d '{"outcome": "approved"}'curl https://api.example.com/v1/refunds \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Idempotency-Key: testing-card-2-refund" \ -H "Content-Type: application/json" \ -d '{"payment_intent": "pi_01j9x2q7s9u1w3y5a7c9e1g3i5", "amount": 2500}'{ "id": "re_01j9x2q8t0v2x4z6b8d0f2h4j6", "object": "refund", "amount": 2500, "currency": "php", "payment_intent": "pi_01j9x2q7s9u1w3y5a7c9e1g3i5", "status": "succeeded", "reason": null, "failure_reason": null, "metadata": {}, "livemode": false, "created": 1790411700}Use your own payment intent's id in the refund. Nothing is returned to anyone, because nothing was charged, but the refund still counts against the payment: 7500 of it is left to refund. See Refunds for the rest.
What the test helper does
Section titled “What the test helper does”POST /v1/test_helpers/payment_intents/{id}/simulate is in the reference. In short:
- A payment with the customer. The intent needs an attempt waiting on the customer: a QR code shown, a cash reference given, or a card page open. One created without a method is that only once it is confirmed with a method, or the customer has chosen one on our checkout page.
- Outcomes by method.
paidandexpiredare forqrphandcash;approved,declined,three_d_secure_passed,three_d_secure_failedandabandonedforcard.decline_reasongoes withdeclinedonly. - Once settled, settled. An intent already paid, canceled, or whose attempt already failed or expired is answered as it is and nothing changes, so asking again is safe. The outcome must still be one for the intent's method:
paidfor a card payment is a400however the intent stands. - An Idempotency-Key is required, as on every write on an intent. The same key replays the first answer.
When it refuses
Section titled “When it refuses”| Status | code | Why |
|---|---|---|
400 | invalid_parameter | param outcome: missing, not an outcome, or not one for the intent's method. param decline_reason: not a reason, or sent with an outcome other than declined. param Idempotency-Key: the header is missing. |
403 | test_mode_only | You used a live key. Nothing is simulated in live mode. |
404 | resource_missing | No such intent for this key. A live intent reads as none to a test key. |
409 | intent_state | No payment with the customer: the intent is not confirmed, has no method chosen at checkout yet, or is processing. |
The helper refuses a live key, and answers like this:
curl https://api.example.com/v1/test_helpers/payment_intents/pi_01j9x2m8k4q7r5t3v6w8y0z2a4/simulate \ -H "Authorization: Bearer at_live_..." \ -H "Idempotency-Key: testing-live-1" \ -H "Content-Type: application/json" \ -d '{"outcome": "paid"}'{ "error": { "type": "authorization_failure", "code": "test_mode_only", "message": "Test helpers take a test key: no outcome is simulated in live mode.", "param": null, "request_id": "req_4f1c0b2e9a7d" }}Nothing is changed, and a live payment cannot be reached this way whichever key is used. Keep the helper out of code that runs against live keys.
Other test values
Section titled “Other test values”A few amounts make a payment do something particular. A card payment of 40400 (PHP 404.00) is charged back once it succeeds, on the simulated card page or with the helper's approved, so you can see a dispute open; see Test mode and test values.
Going live
Section titled “Going live”When your integration handles every outcome above, the steps are these:
- Get your account activated. Live keys come once your business has been verified and your account activated; test keys do not wait for it.
- Make a live key under Developers → API keys, and keep its secret as you kept the test one.
- Check your methods. With a live Access Token,
GET /v1/payment_methodssays which methods your account can take payments with now, and why not for each that cannot (unavailable_reason). A live payment on a method that cannot take it is refused with apolicy_rejection(for examplepayment_method_unavailableorpricing_unavailable; the codes are listed with Create a payment intent). Test mode needed no method enabled and no price set, so check these before the first live payment. - Swap the credentials, and nothing else. The host, the paths and the shapes are the same: change
ci_test_...andsk_test_...forci_live_...andsk_live_..., and exchange them for anat_live_...token. - Set up what is per mode again. A webhook endpoint belongs to the mode of the key that made it, so make it again with the live key (see Webhooks). For embedded checkout, use your live publishable key and add your live origins, which must be
https. - Take the test helper out of the live path. It answers
403test_mode_onlywith a live key. Your tests may call it; your production code should not. - Make a small real payment first, and refund it, before you open to customers.
Your test objects stay in test mode: they are not copied to live, and they do not count there.