Scenarios

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

RoleAPI valueWhat they doarbitratorisSender
Broker — your org adminBROKERCreates and manages the contracttruetrue
BuyerCUSTOMERPays into escrow— omit
SellerPROVIDERReceives payout on settlement— omit

Flow

Your platformBuyerWaffyAutomatic
Setup
1
Set up buyerSign up + IBAN + address
Your platform
2
Set up sellerSign up + IBAN + address
Your platform
3
Create complex contractContainer — holds no money
Your platform
4
Add milestone1000 SAR escrow transaction
Your platform
5
Add parties to milestoneBuyer, seller, broker joined
Your platform
Payment
6
Generate checkout linkScoped to buyer identity
Your platform
7
Buyer paysFunds held in escrow
Buyer
Delivery
8
Admin confirms deliveryACCEPT_CONTRACT called
Your platform
Settlement
9
Admin settlesSETTLE_CONTRACT with cashout split
Your platform
10
Cash out in progressWaffy processes bank transfers
Waffy
11
CompletedAll parties paid out
Automatic

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:

bash
curl -s "$WAFFY_AUTH_URL/api/users/me" \
  -H "Authorization: Bearer $USER_TOKEN"
# save data.id as WAFFY_ADMIN_ID
1

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

bash
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}}"
  }'
json
{
  "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.

bash
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.

bash
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}}"
  }'
2

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

bash
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

bash
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

bash
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}}"
  }'
3

Create the complex contract

The parent is a container — it holds no money. itemPrice: 0 is intentional.

bash
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
  }'
json
{
  "data": {
    "id": "507f1f77bcf86cd799439011",
    "type": "COMPLEX_CONTRACT",
    "senderRole": "BROKER",
    "status": "CREATED"
  }
}

Save data.id as CONTRACT_ID.

4

Add the milestone

This is where the amount lives. Do not pass paymentMethods, isDeliverable, or isInspectable — these are org-level settings configured at onboarding.

bash
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
    }]
  }'
json
{
  "data": {
    "parentContractId": "507f1f77bcf86cd799439011",
    "milestones": [{
      "id": "607f1f77bcf86cd799439022",
      "itemPrice": 1000,
      "status": "CREATED"
    }]
  }
}

Save milestones[0].id as MILESTONE_ID.

5

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.

bash
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

  • BROKER must have arbitrator: true and isSender: true — the contract creator
  • CUSTOMER and PROVIDER — omit isSender entirely

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.

6

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.

bash
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:

bash
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"
json
{
  "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)

javascript
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:

javascript
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.

7

Buyer pays — listen for webhook

When the buyer completes payment Waffy fires a webhook to your configured endpoint:

json
{
  "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.

8

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.

bash
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.

9

Admin settles

Distribute the funds. cashOutAmountList must sum to exactly itemPrice. Note actorRole: CLIENT_ADMIN — only SETTLE uses this role.

bash
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

StepMistakeFix
1–2Skipping IBAN or address for sellerCashout will be blocked until IBAN + address are on file. For buyer, only needed if they may receive a bank transfer refund.
3itemPrice: 0 on milestoneParent contract is 0. Milestone carries the real amount (1000).
4senderRole: PROVIDER on milestoneMust be BROKER — matches the parent contract sender.
4paymentMethods / isDeliverable / isInspectable on milestoneOrg-level settings — passing per-milestone is rejected.
5isSender on PROVIDEROmit entirely — passing it is rejected by the API.
5BROKER missing arbitrator: true or isSender: trueBoth fields are required on BROKER, no exceptions.
6Reusing customer_tokenTokens expire in 60 min — fetch fresh immediately before redirecting to checkout.
6Using WAFFY_CLIENT_ID vs client_id confusionThey are the same credential from your onboarding package.
8actorRole: CLIENT_ADMIN on ACCEPT_CONTRACTACCEPT and REJECT use BROKER. Only SETTLE_CONTRACT uses CLIENT_ADMIN.
9Using client_id as admin_id in cashOutAmountListadmin_id is the user ID from /api/users/me, not your client_id credential.
9cashOutAmountList doesn't sum to itemPriceAll entries must sum to itemPrice exactly — over or under is rejected.
9Calling SETTLE before ACCEPTSETTLE is only valid after ACCEPT_CONTRACT moves the milestone to ready-to-settle.