Build a Payment · Step 4 of 5

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.

  1. 1Auth
  2. 2Users
  3. 3Contract
  4. 4Payment
  5. 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.

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

json
{
  "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.com in the sandbox).
  • payment_methods is filled in from your organization's contracted methods. You do not set it.
  • The path segment after /external/ (here UXFmUCaG) is the contract's shortId. You need it for the ticket.
  • A milestone id that does not exist currently returns a bare 500, not 404.

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

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

ResponseMeaning
200 ticketIssued, even while consent is still pending. Checkout checks it again live.
400 consent_rejectedThe buyer declined your organization. A fresh consent link, OTP verification or checkout login can reverse it, then retry.
400 invalid_grantThe buyer is not linked to this client. Register or link them first.
400 invalid_payment_refNo payable contract with this ref was found for your client. Check the ref.
400 payment_not_allowedThe 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_progressA payment on this contract is being processed. Wait for the result, then request a new ticket.
400 customer_not_partyThis 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.

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

javascript
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

The SDK example above is documented with the legacy customer token. Ask Waffy whether your SDK version accepts a payment ticket before using it with the new flow. The React Native SDK and the Flutter SDK (0.6.0 and later) accept a ticket directly through their 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)

Organizations on the legacy authentication append a 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 carriesWhat the buyer seesUse it when
&ticket=...The payment opens directly.Your backend can request a ticket (steps 1 to 3). The best experience.
&login_hint=%2B9665XXXXXXXXWaffy 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.
NothingThe 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 CUSTOMER party of this contract (you added it with add 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

Even when the buyer is already signed in to Waffy in the same browser, the checkout asks for the verification code, so nobody can pay with someone else's session.

URL-encode the plus sign

Write 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

Build the URL on your server and send the buyer straight to it. The checkout page removes the number from the address bar after it loads, but it can still end up in your own logs and redirects.

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.

ModeTicketURL with login_hintURL with nothing
anyAcceptedAcceptedAccepted
hintAcceptedAcceptedRefused
ticketAcceptedRefusedRefused

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.

ReasonWhat the buyer seesWhat you do
Already used or expiredThis 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 pendingYou 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 declinedYou can't pay through this organization.Nothing: it is terminal. The buyer or Waffy support can reset it.
A problem on Waffy's sideSomething 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:

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

Send it with 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.

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

If your endpoint takes more than a few seconds to respond, Waffy will mark the delivery as failed and retry. Acknowledge immediately with HTTP 200, then enqueue the event for async processing.

Webhook status values

StatusMeaning
PAIDBuyer paid. Funds are in escrow. Move to delivery.
CASHOUT_IN_PROGRESSFunds are being released to parties. No action needed.
COMPLETEDContract is fully settled. All parties have been paid out.