Skip to main content

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

POST /v1/payment_intents/{id}/fulfill
{
"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 to source.transaction_url for the 3D Secure challenge. They return to your callback_url afterwards. 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:

OutcomeIntent consumed?
Payment created — paid, initiatedYes
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​

typeStatusMeaning
invalid_client_secret400The client_secret is missing or does not match this intent.
intent_not_fulfillable400The intent is already fulfilled, canceled, or past its expires_at.
validation_error400The source or another payment field is invalid.
record_not_found404No 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.

tip

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.

note

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.