Migrating a custom CiviCRM donation form to support SCA and the new Order API

This is a highly technical article which is written to share with other developers.

I have a custom donation form that handles multiple currencies and one off and regular donations. The donation form lives in a WordPress website and is completely separate to the CiviCRM instance. The two talk to each other (mostly) through an implementation of CMRF which provides a cached and logged way to access the CiviCRM API. Secure Customer Authentication (SCA) is a new legal requirement that's part of PSD2 legislation in Europe.

Until SCA things were "simple" - Stripe's js dealt with asking for card details and then we sent on a token for taking the payment(s) on the back end. We used the Contribution.transact API, but at the Barcelona Sprint I became aware of a massive push to deprecate that API in favour of the new (and definitely still maturing) Order API.

Dealing with SCA

The custom form uses Stripe Elements to ask for users' card details. There's a fair amount of logic that goes on here and many layers of validation:

  • Basic card details need to be correct (in Stripe parlance, a "payment method" token is created if it's valid)
  • The non-financial form details need to be valid
  • The card and charge need to be "confirmed" and as part of this SCA challenges need to be raised, asked for and accepted.
  • Finally, the charge needs "capturing" - i.e. the money being taken from the card.


Note that 'confirming' a payment means that the money is marked as reserved on my card account. e.g. if I have £100 in my account and I 'confirm' a £100 payment, I now have £0 money available in my account, even though the recipient has not yet got my £100. If a payment is not "captured" then after a week the money is returned to my account. Only then it is captured is the £100 (less fees) given to the organisation.

SCA means that during the confirm phase, it may fail, and require more information from the customer. When that's given things can proceed.

The current CiviCRM Contribution Forms have a bit of an issue in the way they work here: Stripe does it's confirming work before the form is submitted; therefore it happens before the rest of the form can be validated. This can lead to the form being re-presented with errors and a 2nd submission causing a 2nd confirmation - reserving twice the funds!

The way I implemented it for my custom form is:

  1. Basic javascript validation of form before any submission
  2. PHP validation of the form data happens next - if there are errors they are returned to the user before any payment is (attempted to be) confirmed.
  3. Web server, once in posession of valid data, then sends the data to the CiviCRM server to attempt to confirm the payment against the payment method
  4. If SCA is required, CiviCRM's response says so and the Web server passes this back to the browser, along with a Payment Intent ID (again, a Stripe thing). If it's not required, skip to step 8.
  5. A Stripe Javascript handler then deals with asking for whatever SCA requires.
  6. If it can be provided, the form is again submitted, this time it has a Payment Intent ID with it.
  7. The Web server makes a second attempt to confirm the payment, using the payment intent given by sending these to the CiviCRM server
  8. Finally the Web server sends the a custom API on the CiviCRM server all the data for processing.
  9. The CiviCRM server calls:
    • Various APIs in order to find/create the contact, to store their details etc.
    • ContributionRecur.create entity, if needed.
    • Order.create - to create a Pending Contribution record
    • PaymentProcessor.pay - to actually charge the card (which we expect to go through now because it's already 'confirmed')
    • Payment.create - to record a payment against the order which will complete the order.
    • Various other logic to do with thanking people, putting them on a Chassé supporter journey etc.

Using the Order API

Here's the params for the Order.create call.

$order_create_params = [
  'contact_id'             => $contact['id'],
  'total_amount'           => $input_params['amount'],
  'financial_type_id'      => $financial_type_id,
  'receive_date'           => date('YmdHis'),
  'contribution_status_id' => 'Pending',
  'note'                   => isset($input_params['permalink']) ? $input_params['permalink'] : '', // Useful
  'payment_instrument_id'  => 'Stripe',
  'is_test'                => $test_mode,
  'source'                 => empty($input_params['specifics']) ? '' : $input_params['specifics'],
  'invoice_id'             => $inv, // For CiviCRM
  'invoiceID'              => $inv, // For Stripe
  'currency'               => 'GBP', // For CiviCRM
  'currencyID'             => 'GBP', // For Stripe
  'line_items'             => [
      'params'    => [],
      'line_item' => [
          'qty'            => 1,
          'unit_price'     => $input_params['amount'],
          'line_total'     => $input_params['amount'],
          'price_field_id' => 1,


  • I'm unsure of which currency/currencyID is used - I've included them both for good measure as I had found they were both necessary for contribution.transact

  • Likewise invoiceID.

  • The amount field is specified three times! The top level one is not necessary as of CiviCRM 5.20, but that's not out yet.

  • payment_instrument_id here accepts a name, but look out elsewhere.

  • For recurring contributions we also specify is_recur => 1  contribution_recur_id and contributionRecurID and frequency_unit and installments - although those last 2 are surely stored with the contribution recur record already.

Calling PaymentProcessor.pay

Then after this we have the PaymentProcessor.pay method which seemed to need the following for a one off:

'contribution_id'      => $order_create_result['id'],
'payment_processor_id' => $processor['id'], // 'Stripe' does not work here, expects integer.
'amount'               => $input_params['amount'],
'currency'             => 'GBP', // For CiviCRM
'currencyID'           => 'GBP', // For Stripe
'contact_id'           => $contact['id'],
'email'                => $input_params['email'], // Without this it has to guess which to use
'paymentIntentID'      => $input_params['paymentIntentID'],
'description'          => "Donation", // used by Stripe
  •  payment_processor_id does not accept names - you need to look up the integer ID

  • contact_id was a surprise because we're already providing contribution_id

  • Again, not sure about currency IDs.

  • paymentIntentID and description are Stripe specific.

And if it's a recurring contribution, I needed these too:

'paymentMethodID'       => $input_params['paymentMethodID'],
'is_recur'              => 1,
'contribution_recur_id' => $contrib_recur['id'],
'contributionRecurID'   => $contrib_recur['id'], // This is weird. The API specifies with underscores but the payment processors require it camelCase
'frequency_unit'        => "month", // Don't know why this is not picked up from the contribution recur record.
'frequency_interval'    => 1, // If not given, 1 is used (i.e. it ignores the contribution recur record)

Calling Payment.create

Finally the following:

'contribution_id'                   => $order_create_result['id'],
'total_amount'                      => $input_params['amount'],
'is_send_contribution_notification' => '', // Don't send notifications.
'payment_instrument_id'             => 'Stripe',
'payment_processor_id'              => $payment_processor['id'],
'trxn_id'                           => $payment_processor_pay_result['values'][0]['trxn_id'],

Note: Payment.create does not complain if you omit payment_processor_id, however if given then it is stored in the financial transaction. The payment processor is really important - especially if you have multiple accounts. It will also lookup payment_instrument_id from the processor, but since we know it anyway we can pass it in here.

Is this correct?

I'm unsure. It seems to work as far as I can see so far. It strikes me that there's a lot of duplication - things with slightly different names, but also passing in values that - to my eyes - could be avoided because they already belong to a previously referenced entity. I'm particularly unsure about the last call. I'm not sure that there's enough information there.

If anyone would like to chat about this, find me @artfulrobot on https://chat.civicrm.org


Add new comment