Take payment
Get the hosted-checkout URL with the client admin token, get a payment ticket for the buyer, redirect them with the ticket, then wait for the PAID webhook.
- 1Auth
- 2Users
- 3Contract
- 4Payment
- 5Settle
1. Start payment
Use the client admin token. The org token is not accepted here. This returns the hosted checkout URL for the milestone. Use the same client you signed in with when you created the contract.
curl -s -G "$API_BASE_URL/api/external/contracts/startPayment/$MILESTONE_ID/$CLIENT_ID" \ -H "Authorization: Bearer $CLIENT_ADMIN_TOKEN" \ --data-urlencode "redirectUrl=https://yourapp.com/done" \ --data-urlencode "paymentType=PURCHASE"
Response:
{
"data": "https://external.waffyapp.com/external/UXFmUCaG?client_id=...&payment_methods=MANUAL_BANK_TRANSFER,MOYASAR,TABBY,APPLE_PAY"
}- The host is your environment's checkout host (for example
external-dev.waffyapp.comin the sandbox). payment_methodsis filled in from your organization's contracted methods. You do not set it.- The path segment after
/external/(hereUXFmUCaG) is the contract'sshortId. You need it for the ticket. - A milestone id that does not exist currently returns a bare
500, not404.
2. Get a payment ticket
Right before the redirect, ask for a ticket for this buyer and this payment. The buyer must have given consent (see Customers & consent).
curl -s -X POST "$ID_BASE_URL/realms/waffy/protocol/openid-connect/token" \ -H "Accept: application/json" \ --data-urlencode "grant_type=urn:waffy:params:oauth:grant-type:customer-token-exchange" \ --data-urlencode "client_id=$CLIENT_ID" \ --data-urlencode "client_secret=$CLIENT_SECRET" \ --data-urlencode "login_hint=$BUYER_PHONE" \ --data-urlencode "scope=payment:$PAYMENT_REF"
{ "ticket": "a1b2c3...", "expiresIn": 300 }PAYMENT_REF is the contract's shortId, the value in the checkout URL path from step 1. Not an order reference of your own. If you pass the contract's internal id, Waffy uses its short id for you; a different value makes later calls fail. More about tickets in Authentication.
| Response | Meaning |
|---|---|
200 ticket | Issued, even while consent is still pending. Checkout checks it again live. |
400 consent_rejected | The buyer declined your organization. A fresh consent link, OTP verification or checkout login can reverse it, then retry. |
400 invalid_grant | The buyer is not linked to this client. Register or link them first. |
400 invalid_payment_ref | No payable contract with this ref was found for your client. Check the ref. |
400 payment_not_allowed | The contract cannot be paid in its current state, for example it is already paid or cancelled. The message names the status. A partly paid contract can still be paid. |
400 payment_in_progress | A payment on this contract is being processed. Wait for the result, then request a new ticket. |
400 customer_not_party | This buyer is not a CUSTOMER party of the contract. Check the phone number and the ref. |
The error message is in English and Arabic, separated by |.
3. Send the buyer to checkout
Two ways to present the checkout URL. Pick one based on your front-end stack.
Option A: raw URL redirect (frameworks the SDK doesn't cover)
Append the ticket as its own ticket query parameter to the URL from step 1 and redirect the buyer's browser there. Do this from your backend. Waffy's checkout page exchanges the ticket for a real token itself, so your integration ends at the redirect.
const url = checkoutUrl + "&ticket=" + encodeURIComponent(ticket); window.location.href = url; // or Flutter: launchUrl(Uri.parse(url))
Option B: Waffy SDK (Web, Android, iOS, React Native, Flutter)
The SDK shows the checkout in a modal, popup or iframe. Include the script from https://sdk.waffyapp.com/v2/waffy-payment-display.min.js in your page, then call:
WaffyPaymentDisplay.show({
paymentUrl: "https://external.waffyapp.com/external/UXFmUCaG?client_id=...",
userToken: CUSTOMER_TOKEN,
mode: "modal" // or "popup" or "iframe"
});Check the SDK token with Waffy
ticket parameter.Android, iOS, React Native and Flutter use the platform equivalents of the same SDK. Your integration team receives the package references during onboarding.
Legacy: userTokenUrl (deprecated)
userTokenUrl parameter holding the buyer's legacy customer_token instead of a ticket. That keeps working while they move, but new integrations should use the ticket.Checkout without a ticket
The checkout URL from step 1 also works without a ticket. What you append decides what the buyer sees:
| The URL carries | What the buyer sees | Use it when |
|---|---|---|
&ticket=... | The payment opens directly. | Your backend can request a ticket (steps 1 to 3). The best experience. |
&login_hint=%2B9665XXXXXXXX | Waffy sends a verification code to that number and shows the code screen. The buyer cannot change the number. | You know the buyer's phone but cannot request a ticket. |
| Nothing | The buyer types their phone number, then the code. | You send the link to the buyer yourself, or a ticket failed. |
Before any code is sent, Waffy checks the number. All three must be true:
- It belongs to a customer you registered or linked (see Customers & consent).
- It is a
CUSTOMERparty of this contract (you added it withadd parties). - The contract can be paid.
If a check fails, the buyer sees a short message and no code is sent. The message does not say which check failed. A buyer who enters the code also gives your organization their consent, if it was still pending.
The code is always asked
URL-encode the plus sign
login_hint with %2B, not a plain +: in a URL a plain + means a space. Waffy puts a leading space back as a +, but do not rely on it. A number without a + is refused.A phone number in a URL is personal data
Checkout login mode
Waffy sets one mode for each API client. You can see yours in the API clients table of this portal and in the business portal. A client created from 2026-10-19 starts as ticket; older clients are any. To open checkout without a ticket, ask Waffy to change the mode.
| Mode | Ticket | URL with login_hint | URL with nothing |
|---|---|---|---|
any | Accepted | Accepted | Accepted |
hint | Accepted | Accepted | Refused |
ticket | Accepted | Refused | Refused |
A refused URL shows "This organization only accepts payments that are started from its own website or app. Go back there and start the payment again." To change a mode, email developers@waffyapp.com.
A ticket that cannot be used
In every mode, a ticket that fails never starts a login. You sent it on purpose, so the checkout shows the buyer what is wrong, in Arabic and English, and does not ask for a phone number. The messages tell the buyer to go back to your site or app and start again, so build the payment URL with a fresh ticket every time the buyer taps pay. A cached or emailed URL sends them back to the same dead link.
| Reason | What the buyer sees | What you do |
|---|---|---|
| Already used or expired | This payment link has expired. Go back to the website or app where you started and start the payment again. | Request a new ticket. |
| Consent still pending | You haven't approved this organization yet. Check your SMS for the approval link from Waffy and approve it, then go back to the website or app and start the payment again. No SMS? Contact the seller. | Get the buyer's consent (resend the consent link, or your own OTP screen), then request a new ticket. |
| Consent declined | You can't pay through this organization. | Nothing: it is terminal. The buyer or Waffy support can reset it. |
| A problem on Waffy's side | Something went wrong, with a Try again button. If it keeps happening, start the payment again from your site. | Nothing, unless it keeps happening. |
Handle the PAID event
When the buyer completes payment, you will receive:
{
"contractId": "607f1f77bcf86cd799439022",
"status": "PAID",
"referenceId": "txn_abc123"
}contractId is the milestone ID. Always verify the Waffy-Signature header before processing.
Register the webhook URL yourself
PATCH /api/clients/{your client id} and your client admin token. The call is described on the Webhooks API page. Do it once per environment, and again before go-live with your production URL.Verify the webhook signature
Every webhook request carries a Waffy-Signature header. Compute HMAC-SHA256 of the raw request body using your webhook secret and compare. Reject any request that does not match.
# Header format
Waffy-Signature: sha256=abc123def456...
# Verification (Node.js example)
const expected = "sha256=" + crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(rawBody)
.digest("hex");
if (expected !== req.headers["waffy-signature"]) {
return res.status(401).send("Signature mismatch");
}Retry policy
Waffy retries failed webhook deliveries 3 times: after 1 s, 10 s, and 100 s. Your endpoint must respond HTTP 200 immediately and process the event asynchronously.
Respond 200 first, process second
Webhook status values
| Status | Meaning |
|---|---|
PAID | Buyer paid. Funds are in escrow. Move to delivery. |
CASHOUT_IN_PROGRESS | Funds are being released to parties. No action needed. |
COMPLETED | Contract is fully settled. All parties have been paid out. |