Guide 10 · Developer docs

Forms and data capture

Building forms with the Forms module: how a form is put together and placed on a page, validating and branching, presenting it, where submissions are stored, and what happens after one arrives, from jobs and records to CRMs and payments.

L1–L2
Unit 06 · Acting on a submission

Pushing to a CRM

Sending each submission to a CRM so that the CRM being down never fails or loses one: an idempotent push with retries, an hourly re-send that needs no state, and a daily check for anything still missing.

Who this is for. The developer connecting a form to a CRM or another external system. After this page you can send every submission to the CRM so that an outage delays an enquiry but never fails or loses it, and spot the ones that need a person.

Sending each enquiry to a CRM is the integration clients ask for most. Its failure is quiet: the CRM is down for an hour, the push fails, and nobody notices until sales asks where the week's leads went. This page builds the push so that the CRM being unavailable never fails or loses a submission: an immediate call with retries, a scheduled re-send, and a daily check. It applies the rules in The integration pattern catalogue to forms; Worked integration: order fulfilment is the same shape at full size.

The shape

The submission never waits for the CRM. The visitor's request ends when the submission is saved and the result step is shown. The push runs afterwards, in an asynchronous job, so an outage costs a delay, not an enquiry. What it does cost is the need to send again, and that is the design problem.

A job cannot mark a submission as sent: record actions cannot write to submissions, and the API cannot change a completed one. So the platform keeps no "sent" flag, and the CRM's own record of what it has received does that job. Each push carries the submission's id as the CRM's external id, and the CRM upserts on it: a second push of the same submission updates the lead it already has, or answers 409, and creates nothing. With that one property, sending again is always safe, and the retry needs no state of its own.

Three jobs, all asynchronous, in a category CRM:

  1. CRM: push enquiry, started by the other two with a submission id. The only job that talks to the CRM.
  2. Form: contact → CRM, on the form's Completed event. Starts job 1.
  3. Schedule: hourly → re-send recent enquiries. Starts job 1 for every submission completed in the last 26 hours.

Jobs 1 and 3 read submissions through the site's own REST API, with HTTP request actions: the Retrieve records action cannot read form submissions. Site data, set separately on every environment:

  • crm-api-url (the CRM's sandbox everywhere but production) and crm-api-key, the CRM's own credential.
  • contact-form-id, the form's id.
  • site-url, the site's own address, such as https://www.example.com, with no trailing slash. It differs per environment, and the site record has no hostname method to build it from.
  • site-api-token, the credential of an admin user made for the integration, with permission to read form submissions and nothing else. OAuth is the preferred credential and an API key the older option; Authentication and authorisation explains both.

Site data is readable by every backend user the API admits, so keep each credential as narrow as the integration allows (how exposed site data is).

1. CRM: push enquiry

No trigger of its own; the Trigger job actions below start it with event data {"submissionId": 2152}. It loads the submission itself, so the first push and every re-send send exactly the same thing, built by the same template.

HTTP request get-submission: GET, header Authorization: Bearer {$siteData->get('site-api-token')} (Twig {{ siteData.get('site-api-token') }}), retries 3, and the URL written on one line:

Template code
{$in = $event->getData()}{$siteData->get('site-url')}/cdna-api/forms/submissions/{$siteData->get('contact-form-id')|intval}?id={$in.submissionId|intval}&properties={urlencode('{ id, completed, data }')}
{{ siteData.get('site-url') }}/cdna-api/forms/submissions/{{ siteData.get('contact-form-id')|integer }}?id={{ event.getData().submissionId|integer }}&properties={{ '{ id, completed, data }'|url_encode }}

Condition found, template type, matching when exactly one complete submission came back:

Template code
{$rows = $runData->get('get-submission')->responseData.data}
{if $rows->count() == 1}{$result->setMatching(true)}{/if}
{% if runData.get('get-submission').responseData.data.count() == 1 %}{% do result.setMatching(true) %}{% endif %}

Inside it, HTTP request push-lead: POST to {$siteData->get('crm-api-url')}/leads, retries 3, timeout 30, accepted status codes 2**,409, these headers:

Template code
Authorization: Bearer {$siteData->get('crm-api-key')}
{$in = $event->getData()}Idempotency-Key: form-submission-{$in.submissionId|intval}
Authorization: Bearer {{ siteData.get('crm-api-key') }}
Idempotency-Key: form-submission-{{ event.getData().submissionId|integer }}

and this body:

Template code
{$rows = $runData->get('get-submission')->responseData.data->toArray()}
{$submission = $rows[0]}
{$answers = $submission.data}
{$body = [
  'externalId' => "form-submission:{$submission.id|intval}",
  'email' => strtolower(trim($answers.email|default:'')),
  'firstName' => trim($answers.firstName|default:''),
  'lastName' => trim($answers.lastName|default:''),
  'message' => $answers.message|default:'',
  'marketingConsent' => !empty($answers.marketingConsent),
  'source' => 'website-contact-form'
]}
{$body|json_encode}
{% set submission = runData.get('get-submission').responseData.data.toArray()|first %}
{% set answers = submission.data %}
{{ {
  externalId: 'form-submission:' ~ submission.id,
  email: answers.email|default('')|trim|lower,
  firstName: answers.firstName|default('')|trim,
  lastName: answers.lastName|default('')|trim,
  message: answers.message|default(''),
  marketingConsent: answers.marketingConsent|default('') is not empty,
  source: 'website-contact-form'
}|json_encode }}

Rename the keys to the CRM's fields; the rest of the shape stays. Three choices in it are deliberate:

  • 409 counts as success. A CRM that refuses a duplicate external id is telling you the lead already arrived, which is what a re-send hopes to hear.
  • Consent is sent as what the visitor chose, every time, so the CRM never adds someone to marketing who did not tick the box. Field ownership (which system may change which field afterwards) is set out in The integration pattern catalogue.
  • No upload links. If the CRM needs an uploaded file, send its link to an endpoint that fetches it at once, never into a field people will copy around (see File uploads).

2. Form: contact → CRM

Trigger: entity event, form submissions, method Completed, and the contact form in its Parent Form field. One action, Trigger job, target CRM: push enquiry, event data:

Template code
{$data = ['submissionId' => $event->getRecordId()]}
{$data|json_encode}
{{ {submissionId: event.getRecordId()}|json_encode }}

The push is its own job run, so a CRM outage fails that run alone, and the other jobs on the form (the staff notification, the person record) are untouched. Keep them in separate jobs for the same reason.

3. Schedule: hourly → re-send recent enquiries

Schedule 15 * * * *, limited to the site. HTTP request recent: GET with the same site-api-token header, retries 3, URL on one line, listing the submissions completed in the last 26 hours:

Template code
{$since = date_create('-26 hours')}{$siteData->get('site-url')}/cdna-api/forms/submissions/{$siteData->get('contact-form-id')|intval}?%21completed=NULL&%3Ecompleted={urlencode($since->format('c'))}&orderBy=completed&limit=500&properties={urlencode('{ id }')}
{{ siteData.get('site-url') }}/cdna-api/forms/submissions/{{ siteData.get('contact-form-id')|integer }}?%21completed=NULL&%3Ecompleted={{ date('-26 hours')|date('c')|url_encode }}&orderBy=completed&limit=500&properties={{ '{ id }'|url_encode }}

The operators are encoded in the URL (%21 for !, %3E for >), and the date carries its offset, which the API honours. Then a Loop resend: source template, sequential, wait done, stop on error off, adding the ids with the loop helper:

Template code
{$recent = $runData->get('recent')->responseData.data}
{$loopHelper->addElements($recent->toArray())}
{% do loopHelper.addElements(runData.get('recent').responseData.data.toArray()) %}

Inside it, Trigger job CRM: push enquiry with event data:

Template code
{$data = ['submissionId' => $loopRecord.id|intval]}
{$data|json_encode}
{{ {submissionId: loopRecord.id|integer}|json_encode }}

Every submission of the last day is pushed again every hour, and the CRM's upsert makes that harmless. The schedule gives the retry policy from The integration pattern catalogue: three quick retries inside each push, then a fresh attempt every hour for a day. It costs a CRM call per recent submission per hour, which is nothing for an enquiry form. For a form with thousands of submissions a day, ask the CRM which external ids it already holds and push only the others.

When the CRM is down at submit time

What happens, in order:

  1. The visitor submits. The submission is saved, the result step is shown, and the staff notification goes out. Nothing they see depends on the CRM.
  2. Job 2 starts job 1. push-lead fails, is retried three times within about two seconds, and its run ends in error.
  3. Within the hour, job 3 lists the submission and pushes it again, and again every hour after, until the CRM answers.
  4. After 26 hours the submission leaves the re-send window. Anything still missing then is a dead letter.

Dead letters need a person. Add a daily job, as in the worked integration, that compares yesterday's count of completed submissions (the totalCount of the same listing with a one-day window) with the number of leads the CRM received from the website, and emails a shared inbox when they differ. An integration that fails silently is the same as no integration, a week later.

Testing it

On a development site with the CRM's sandbox in crm-api-url:

  • Submit once: one lead appears, carrying the submission's external id.
  • Run job 3 by hand: no second lead appears. If one does, the CRM is not upserting on the external id, and the design above sends duplicates; fix that before going further.
  • Point crm-api-url at an address that refuses connections, submit, and confirm the submission completes normally, job 1's run fails with the reason in its log, and the lead arrives at job 3's next run after the address is restored.
Go deeper

Was this page helpful?

Your answer goes to the docs team.