Simple escrow
One buyer · one seller · one milestone · no inspection. The baseline flow every other scenario builds on.
Escrow protects both sides: the buyer's money is held by Waffy until your platform confirms delivery, then released to the seller. Neither party can walk away with both the goods and the money.
Parties
| Role | API value | What they do | arbitrator | isSender |
|---|---|---|---|---|
| Broker — your org admin | BROKER | Creates and manages the contract | true | true |
| Buyer | CUSTOMER | Pays into escrow | — | — omit |
| Seller | PROVIDER | Receives payout on settlement | — | — omit |
Flow
Walkthrough
Before you start
You need APP_TOKEN, USER_TOKEN, and WAFFY_ADMIN_PHONE — see the Authentication page.
You also need WAFFY_ADMIN_ID — your org admin's Waffy user ID. Fetch it once and store it:
curl -s "$WAFFY_AUTH_URL/api/users/me" \ -H "Authorization: Bearer $USER_TOKEN" # save data.id as WAFFY_ADMIN_ID
Set up the buyer
Three calls in order: register, add IBAN, add address. IBAN and address are only required if the buyer may receive a bank transfer refund — card payments (Mada, Visa, Apple Pay) refund directly to the original payment method.
1a — Register
curl -s "$WAFFY_AUTH_URL/v2/api/users/sign-up" \
-H "Authorization: Bearer $APP_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"phoneNumber": "{{BUYER_PHONE}}",
"firstName": "{{BUYER_FIRST_NAME}}",
"lastName": "{{BUYER_LAST_NAME}}",
"clientUserId": "{{YOUR_INTERNAL_BUYER_ID}}"
}'{
"data": {
"id": 99001,
"phoneNumber": "{{BUYER_PHONE}}",
"preExistingUser": false,
"clientUserToken": "eyJhbGc..."
}
}clientUserToken is the buyer's Waffy password — store it encrypted
Save data.id as BUYER_ID, data.phoneNumber as BUYER_PHONE, and clientUserToken as BUYER_CLIENT_TOKEN. Never log it. Never expose it to the browser. You will use it to generate checkout tokens. Do not send password in the sign-up body — Waffy generates and returns it.
1b — Add IBAN
Required for refund payouts. Saudi format: SA + 22 digits.
curl -s "$WAFFY_AUTH_URL/api/users/$BUYER_ID/IBAN" \
-H "Authorization: Bearer $USER_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"iban": "{{BUYER_IBAN}}",
"currency": "SAR",
"beneficiaryName": "{{BUYER_FULL_NAME}}",
"nationalId": "{{BUYER_NATIONAL_ID}}",
"accountType": "PERSONAL"
}'1c — Add address
Required alongside IBAN for bank transfer cash-out.
curl -s "$WAFFY_AUTH_URL/api/profiles/$BUYER_ID/addresses" \
-H "Authorization: Bearer $USER_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"addressLabel": "HOME",
"street": "{{BUYER_STREET}}",
"district": "{{BUYER_DISTRICT}}",
"city": "{{BUYER_CITY}}",
"countryCode": "SA",
"postalCode": "{{BUYER_POSTAL_CODE}}"
}'Set up the seller
Same three calls for the seller. IBAN and address are required for the seller — cashout will be blocked until they are on file.
2a — Register
curl -s "$WAFFY_AUTH_URL/v2/api/users/sign-up" \
-H "Authorization: Bearer $APP_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"phoneNumber": "{{SELLER_PHONE}}",
"firstName": "{{SELLER_FIRST_NAME}}",
"lastName": "{{SELLER_LAST_NAME}}",
"clientUserId": "{{YOUR_INTERNAL_SELLER_ID}}"
}'Save data.id as SELLER_ID.
2b — Add IBAN
curl -s "$WAFFY_AUTH_URL/api/users/$SELLER_ID/IBAN" \
-H "Authorization: Bearer $USER_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"iban": "{{SELLER_IBAN}}",
"currency": "SAR",
"beneficiaryName": "{{SELLER_FULL_NAME}}",
"nationalId": "{{SELLER_NATIONAL_ID}}",
"accountType": "PERSONAL"
}'2c — Add address
curl -s "$WAFFY_AUTH_URL/api/profiles/$SELLER_ID/addresses" \
-H "Authorization: Bearer $USER_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"addressLabel": "HOME",
"street": "{{SELLER_STREET}}",
"district": "{{SELLER_DISTRICT}}",
"city": "{{SELLER_CITY}}",
"countryCode": "SA",
"postalCode": "{{SELLER_POSTAL_CODE}}"
}'Create the complex contract
The parent is a container — it holds no money. itemPrice: 0 is intentional.
curl -s "$WAFFY_BASE_URL/api/external/contracts" \
-H "Authorization: Bearer $USER_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"type": "COMPLEX_CONTRACT",
"senderRole": "BROKER",
"contractClassification": "BASIC",
"currency": "SAR",
"itemPrice": 0,
"itemDetail": {
"title": "Order #1001",
"description": "Product description here"
},
"returnPolicy": "NO_RETURN",
"returnFeePayee": "PROVIDER",
"waffyTermsAccepted": true,
"isParent": true
}'{
"data": {
"id": "507f1f77bcf86cd799439011",
"type": "COMPLEX_CONTRACT",
"senderRole": "BROKER",
"status": "CREATED"
}
}Save data.id as CONTRACT_ID.
Add the milestone
This is where the amount lives. Do not pass paymentMethods, isDeliverable, or isInspectable — these are org-level settings configured at onboarding.
curl -s -X PATCH "$WAFFY_BASE_URL/api/external/contracts/$CONTRACT_ID/milestones" \
-H "Authorization: Bearer $USER_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"milestones": [{
"type": "MILESTONE_CONTRACT",
"senderRole": "BROKER",
"itemDetail": {
"title": "Order #1001",
"description": "Product description here"
},
"itemPrice": 1000,
"currency": "SAR",
"returnPolicy": "NO_RETURN",
"returnFeePayee": "PROVIDER",
"deadLine": "2026-12-31T23:59:59Z",
"waffyTermsAccepted": true
}]
}'{
"data": {
"parentContractId": "507f1f77bcf86cd799439011",
"milestones": [{
"id": "607f1f77bcf86cd799439022",
"itemPrice": 1000,
"status": "CREATED"
}]
}
}Save milestones[0].id as MILESTONE_ID.
Add parties to the milestone
All three parties in one call using phone numbers. Once this lands, the milestone is payable automatically — no separate publish step for API integrations.
curl -s -X PATCH "$WAFFY_BASE_URL/api/external/contracts/$CONTRACT_ID/parties" \
-H "Authorization: Bearer $USER_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"mileStonesParties\": {
\"$MILESTONE_ID\": [
{
\"phoneNumber\": \"$BUYER_PHONE\",
\"role\": \"CUSTOMER\",
\"amount\": 1000,
\"arbitrator\": false
},
{
\"phoneNumber\": \"$WAFFY_ADMIN_PHONE\",
\"role\": \"BROKER\",
\"amount\": 960,
\"arbitrator\": true,
\"isSender\": true
},
{
\"phoneNumber\": \"$SELLER_PHONE\",
\"role\": \"PROVIDER\",
\"amount\": 40
}
]
}
}"Party rules — no exceptions
- •
BROKERmust havearbitrator: trueandisSender: true— the contract creator - •
CUSTOMERandPROVIDER— omitisSenderentirely
What the buyer and seller experience
If your org has invitationAllowed: false (most orgs) — parties are auto-joined immediately after this call. No action required from buyer or seller before payment can proceed.
If invitationAllowed: true — buyer and seller receive an invitation in the Waffy app and must accept before payment becomes available. Your account manager confirms which applies to your org during onboarding.
Generate the buyer's checkout link
First exchange the stored BUYER_CLIENT_TOKEN for a short-lived checkout token. Fetch this immediately before redirecting — tokens expire in 60 minutes.
curl -s "$WAFFY_AUTH_URL/oauth/token" \ -u "$WAFFY_CLIENT_ID:$WAFFY_CLIENT_PASSWORD" \ -H "Content-Type: application/x-www-form-urlencoded" \ --data-urlencode "grant_type=password" \ --data-urlencode "username=$BUYER_PHONE" \ --data-urlencode "password=$BUYER_CLIENT_TOKEN" \ --data-urlencode "scope=WRITE" # save access_token as CUSTOMER_TOKEN
Then get the checkout URL. WAFFY_CLIENT_ID here is the same client_id from your onboarding credentials:
curl -s "$WAFFY_BASE_URL/api/external/contracts/startPayment/$MILESTONE_ID/$WAFFY_CLIENT_ID?redirectUrl=https://yourapp.com/done&paymentType=PURCHASE" \ -H "Authorization: Bearer $USER_TOKEN"
{
"data": "https://app.waffyapp.com/external/UXFmUCaG?client_id=...&payment_methods=MADA,VISA,APPLE_PAY"
}payment_methods is auto-populated from your org's contracted methods — you do not set it.
Two ways to present the checkout URL:
Option A — Raw URL redirect (Flutter and frameworks the SDK doesn't cover)
const url = paymentUrl + "&userTokenUrl=" + CUSTOMER_TOKEN; window.location.href = url; // or Flutter: launchUrl(Uri.parse(url))
Option B — Waffy SDK (recommended for Web, Android, iOS, React Native)
Include the SDK script from https://sdk.waffyapp.com/v2/waffy-payment-display.min.js, then call:
WaffyPaymentDisplay.show({
paymentUrl: "https://app.waffyapp.com/external/UXFmUCaG?client_id=...",
userToken: CUSTOMER_TOKEN,
mode: "redirect" // or "popup" or "modal"
});Android / iOS / React Native use the platform SDK equivalents — your integration team receives package references during onboarding.
Buyer pays — listen for webhook
When the buyer completes payment Waffy fires a webhook to your configured endpoint:
{
"contractId": "607f1f77bcf86cd799439022",
"status": "PAID",
"referenceId": "txn_abc123"
}contractId is the milestone ID.
Always verify the webhook signature
Every request carries a Waffy-Signature header. HMAC-SHA256 the raw body with your webhook secret and compare before acting. Respond HTTP 200 immediately and process async.
Waffy retries failed deliveries 3 times: after 1s, 10s, and 100s. If your endpoint doesn't respond within a few seconds, the delivery is marked failed and retried.
Funds are now held in escrow. Milestone is waiting for delivery.
Admin confirms delivery
Once you confirm delivery happened on your side, call ACCEPT_CONTRACT. Note actorRole: BROKER — accept and reject are always broker-side actions.
curl -s "$WAFFY_BASE_URL/contract-actions/external" \
-H "Authorization: Bearer $USER_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"contractId\": \"$MILESTONE_ID\",
\"userId\": \"$WAFFY_ADMIN_ID\",
\"actorRole\": \"BROKER\",
\"contractAction\": \"ACCEPT_CONTRACT\",
\"contractType\": \"MILESTONE_CONTRACT\"
}"Inspection is off for this scenario
Your platform calls ACCEPT_CONTRACT directly — no buyer interaction required.
If your org has inspection enabled, the flow is different: after PAID you receive a WAITING_FOR_DELIVERY webhook, the buyer reviews in the Waffy app, and you receive either ACCEPT_CONTRACT or REJECT_CONTRACT as a webhook event before you can call SETTLE_CONTRACT. Your account manager will confirm whether inspection is enabled for your org.
After this call, the milestone moves to ready-to-settle. You will receive a CASHOUT_IN_PROGRESS webhook once you trigger settlement in the next step.
Admin settles
Distribute the funds. cashOutAmountList must sum to exactly itemPrice. Note actorRole: CLIENT_ADMIN — only SETTLE uses this role.
curl -s "$WAFFY_BASE_URL/contract-actions/external" \
-H "Authorization: Bearer $USER_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"contractId\": \"$MILESTONE_ID\",
\"userId\": \"$WAFFY_ADMIN_ID\",
\"actorRole\": \"CLIENT_ADMIN\",
\"receiverId\": \"$SELLER_ID\",
\"senderId\": \"$WAFFY_ADMIN_ID\",
\"contractType\": \"MILESTONE_CONTRACT\",
\"contractAction\": \"SETTLE_CONTRACT\",
\"cashOutAmountList\": [
{ \"id\": \"$SELLER_ID\", \"amountDue\": $SELLER_AMOUNT },
{ \"id\": \"$WAFFY_ADMIN_ID\", \"amountDue\": $BROKER_AMOUNT }
]
}"Settlement math — must be exact
The fee is calculated after payment based on the payment method used and your org's agreement — not pre-calculated at milestone creation. The total of all amountDue entries must equal itemPrice exactly — any mismatch rejects the call.
Waffy processes bank transfers. You will receive CASHOUT_IN_PROGRESS then COMPLETED webhooks when all parties are paid out.
Common mistakes
| Step | Mistake | Fix |
|---|---|---|
| 1–2 | Skipping IBAN or address for seller | Cashout will be blocked until IBAN + address are on file. For buyer, only needed if they may receive a bank transfer refund. |
| 3 | itemPrice: 0 on milestone | Parent contract is 0. Milestone carries the real amount (1000). |
| 4 | senderRole: PROVIDER on milestone | Must be BROKER — matches the parent contract sender. |
| 4 | paymentMethods / isDeliverable / isInspectable on milestone | Org-level settings — passing per-milestone is rejected. |
| 5 | isSender on PROVIDER | Omit entirely — passing it is rejected by the API. |
| 5 | BROKER missing arbitrator: true or isSender: true | Both fields are required on BROKER, no exceptions. |
| 6 | Reusing customer_token | Tokens expire in 60 min — fetch fresh immediately before redirecting to checkout. |
| 6 | Using WAFFY_CLIENT_ID vs client_id confusion | They are the same credential from your onboarding package. |
| 8 | actorRole: CLIENT_ADMIN on ACCEPT_CONTRACT | ACCEPT and REJECT use BROKER. Only SETTLE_CONTRACT uses CLIENT_ADMIN. |
| 9 | Using client_id as admin_id in cashOutAmountList | admin_id is the user ID from /api/users/me, not your client_id credential. |
| 9 | cashOutAmountList doesn't sum to itemPrice | All entries must sum to itemPrice exactly — over or under is rejected. |
| 9 | Calling SETTLE before ACCEPT | SETTLE is only valid after ACCEPT_CONTRACT moves the milestone to ready-to-settle. |