Fulfill an Intent
Fulfillment is where the customer's payment details are submitted. It runs in the browser
with your publishable key and the client_secret your backend
obtained when it created the intent.
Endpoint: POST /v1/payment_intents/{id}/fulfill
Authentication: Publishable key
- JSON
- cURL
{
"client_secret": "pi_secret_7Kq2mXbN9vT4wPzR1sYcHdLjF6aGuE8oQ3iK5nB0xVtZrWyM",
"source": {
"type": "creditcard",
"name": "John Doe",
"number": "4111111111111111",
"month": "12",
"year": "2030",
"cvc": "123"
}
}
curl -X POST https://api.moyasar.com/v1/payment_intents/{intent_id}/fulfill \
-u pk_test_YOUR_PUBLISHABLE_KEY: \
-H "Content-Type: application/json" \
-d '{
"client_secret": "pi_secret_7Kq2mXbN9vT4wPzR1sYcHdLjF6aGuE8oQ3iK5nB0xVtZrWyM",
"source": {
"type": "creditcard",
"name": "John Doe",
"number": "4111111111111111",
"month": "12",
"year": "2030",
"cvc": "123"
}
}'
Notice what is missing from the body: there is no amount, currency, description or
callback_url. Those come from the intent. Sending them is harmless — they are simply
replaced with the approved values before the request is validated.
Everything else you would send to Create Payment
is accepted here: source for any payment method, plus metadata, given_id,
invoice_id, splits and apply_coupon.
The response is a payment
A successful fulfillment returns a payment object, 201 Created, identical to what
Create Payment returns:
{
"id": "3f1c9d84-5b2a-4e7f-9c10-6d8ba24e5f73",
"status": "initiated",
"amount": 10000,
"currency": "SAR",
"description": "Order #1234",
"callback_url": "https://example.com/checkout/payer-return",
"source": {
"type": "creditcard",
"company": "visa",
"transaction_url": "https://api.moyasar.com/v1/transaction_auths/..."
}
}
Handle it exactly as you handle a directly created payment:
initiated— redirect the payer tosource.transaction_urlfor the 3D Secure challenge. They return to yourcallback_urlafterwards. See 3DS in a Payment.paid— the charge succeeded.failed— the charge was declined. See Payment Errors.
Always confirm the outcome server-side by fetching the payment with your secret key before you fulfill the order. Never trust the browser's report of a payment result.
Posting an HTML form directly
If you submit an HTML form to the fulfill endpoint rather than calling it with JSON — the
approach in Custom UI — Moyasar responds with a redirect
into the payment's next step instead of a JSON body, the same as the payments endpoint. In
that case client_secret and publishable_api_key are form fields:
<form action="https://api.moyasar.com/v1/payment_intents/INTENT_ID/fulfill" method="POST">
<input type="hidden" name="publishable_api_key" value="pk_test_YOUR_PUBLISHABLE_KEY" />
<input type="hidden" name="client_secret" value="pi_secret_..." />
<input type="hidden" name="source[type]" value="creditcard" />
<!-- card fields -->
<button type="submit">Pay</button>
</form>
One intent, one payment
Fulfillment is single-use. The first request that gets past authorization, validation and
the client_secret check claims the intent; the intent becomes fulfilled and any later
attempt returns intent_not_fulfillable. Two simultaneous requests cannot both succeed.
A decline still consumes the intent. If the card is declined, the fulfillment
succeeded in creating a payment — that payment simply failed. To let the customer try
another card, your backend creates a new intent.
Requests that are rejected before a payment is attempted do not consume the intent, so
the customer can correct the problem and retry with the same client_secret:
| Outcome | Intent consumed? |
|---|---|
Payment created — paid, initiated | Yes |
Payment created — failed (declined) | Yes |
Wrong or missing client_secret (400) | No |
Invalid card or other validation error (400) | No |
Not authorized (403) | No |
Intent expired, canceled, or already used (400) | No |
Errors
type | Status | Meaning |
|---|---|---|
invalid_client_secret | 400 | The client_secret is missing or does not match this intent. |
intent_not_fulfillable | 400 | The intent is already fulfilled, canceled, or past its expires_at. |
validation_error | 400 | The source or another payment field is invalid. |
record_not_found | 404 | No such intent on your account. |
An intent id that belongs to another merchant returns 404, not 403 — an intent is only
ever visible to the account that created it.
Show intent_not_fulfillable as "this checkout session has expired, please start again"
rather than a payment failure. Nothing was charged, and the fix is a new intent — not a
different card.
Using the Moyasar.js payment form
The payment form can fulfill an intent instead
of creating a payment directly. Set use_payment_intent and pass the intent your backend
created:
Moyasar.init({
element: '.mysr-form',
amount: 10000,
currency: 'SAR',
description: 'Order #1234',
publishable_api_key: 'pk_test_YOUR_PUBLISHABLE_KEY',
callback_url: 'https://example.com/checkout/payer-return',
methods: ['creditcard'],
use_payment_intent: true,
payment_intent: {
id: '8f4d1c3e-6a52-4f0b-9a1e-2c7b5d8e0f31',
client_secret: 'pi_secret_7Kq2mXbN9vT4wPzR1sYcHdLjF6aGuE8oQ3iK5nB0xVtZrWyM',
},
});
The form then posts to the fulfill endpoint and adds the client_secret for you. The
amount and currency you pass to Moyasar.init are still used to render the form, so
keep them equal to the intent's — the intent's values are what get charged regardless.
To create the intent only once the customer actually presses pay, return it from the
on_initiating hook instead of hardcoding it:
Moyasar.init({
// ...
use_payment_intent: true,
on_initiating: async () => {
const response = await fetch('/checkout/payment-intent', { method: 'POST' });
const intent = await response.json();
return {
payment_intent: {
id: intent.id,
client_secret: intent.client_secret,
},
};
},
});
This is the better shape for most checkouts: no intent is created for customers who never reach the pay button, and the intent's one-hour window starts when the customer is ready to pay rather than when the page loaded.
Payment intent support in the payment form requires Moyasar.js v2.3.0 or later. On earlier versions, use the fulfill endpoint directly as shown above.