Send new buyers to your CRM
A buyer registers in your shop, and once they are let in, the CRM has them: the person, the company they founded, and the CRM's own id stored on the contact. From then on the CRM contact and the shop contact find each other by that id. A company joins by a field on the CRM account instead, because a company cannot carry the CRM's id. Registered buyers' personal data goes to a third-party system here, so check your processing agreement with the CRM vendor before you turn it on. This guide builds that path: an Integration Studio workflow for the way out, one API call for the id on the way back, and a job that catches the contacts no event announced.
This is for integrators connecting a CRM to a B2B shop. You need a tenant with the Customers core app, Integration Studio enabled on it, access to Cockpit to create API keys and the workflow, and a CRM with an HTTPS API that can create a contact and an account and tell you its id. Integration Studio is experimental and its schemas are drafts, so expect a workflow built today to need a migration later. Statements here about behaviour the product does not yet support can change (as of 2026-11).
export REVENEXX_TENANT="<TENANT_SLUG>"
export REVENEXX_API_KEY="rvxk_..."
export REVENEXX_DEV_API_KEY="rvxk_..."
REVENEXX_API_KEY is the key from step 2. REVENEXX_DEV_API_KEY is a separate development key that carries *, used for one call only, the event catalog in step 3. Every call goes through the API gateway with the same two headers. The shell examples use jq to pick values out of the answers.
How the path runs
A buyer registers, and a merchant approves the application, or the shop lets them straight in. That fires an event. The event starts a workflow run. The run reads the contact and its company, creates both in the CRM, and writes the CRM's contact id onto the contact as external_id. A scheduled job later lists the contacts that still have no external_id and sends those the same way.
The way out runs in a workflow because no server of yours has to be running for it, and a failure lands on a run you can read and replay. A webhook to your own middleware works too, if you would rather run that code yourself. Choose an event surface compares the two. The same checks apply either way: the event is delivered at least once, so a repeat must not create a second CRM record.
1. Decide who owns which contact
Before any feed, settle which system is the master for which record, and write it down. Choose which system owns each entity walks through the decision. This guide takes the common answer for contacts: the shop owns buyers who register themselves, and the CRM owns the contacts it creates. The CRM gets a copy of each self-registered buyer once, at the moment the shop has accepted them, and the two are joined on the contact's external_id from then on.
Two consequences to settle with whoever runs the CRM:
- This guide is one way. A change to a contact's phone number in the shop after the hand-off does not reach the CRM, and a change in the CRM does not reach the shop. Decide which system wins for the fields both sides edit, and keep the other from editing them.
- The company has a different join key from the person. A contact joins on
external_id. A company has no writableexternal_idin the API (as of 2026-11). It carriescustomer_number, the number in your ERP, and that is not the CRM's id. So the CRM side keeps the revenexx companyidin a field of its own on the account, and finds the account by that.
2. Create the key
Create an API key in Cockpit (see API keys and create an API key) with custom scopes instead of a preset (which scope covers which app):
| Scope | For |
|---|---|
customers.contacts.get | Reading the contact (step 5) |
customers.organizations.get | Reading the company (step 5) |
customers.contacts.update | Writing the CRM's id onto the contact (step 7) |
customers.contacts.list | The backfill and reconcile job (step 8) |
Narrow scopes limit which operations a key can call, not which records: this key can update any contact on the tenant. Keep it inside Integration Studio and your own job, never in a storefront or a browser.
Put the key into a Revenexx API credential under Integration Studio → Credentials, with your tenant slug, and test it there. The test proves the key and the tenant, not the scopes: a missing scope fails at the step that needs it, with an authorization error. See Reaching Revenue Cloud.
3. Look up the topic to subscribe to
Which event tells you "this buyer is in" depends on the tenant's registration_mode, a per-market setting, so two markets of one tenant can differ (Storefront accounts):
| Registration mode | What fires | Subscribe to |
|---|---|---|
open | contact.registered with registration_status: "approved": the account works at once | contact.registered |
approval_required | contact.registered with registration_status: "pending", then contact.registration_approved when a merchant decides | contact.registration_approved |
In approval mode contact.registered also fires for applications that are still pending, and some of those are rejected, so sending on it would put people into the CRM whom you never let in. If your tenant has both kinds of market, build one workflow per topic and let the registered one stop on a pending application (step 4).
The event's name is not the string a workflow trigger matches. A trigger matches the topic, which puts the vendor and the app in front: <vendor>.<app>.<name>. Read it from your tenant's event catalog rather than typing it from memory. The catalog has no documented scope yet (as of 2026-11), so call it with the development key:
curl -s https://api.revenexx.com/v1/events/catalog \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_DEV_API_KEY" \
| jq -r '.emits[] | select(.app == "customers" and (.name | startswith("contact.registration") or . == "contact.registered")) | .topic'
revenexx.customers.contact.registered
revenexx.customers.contact.registration_approved
revenexx.customers.contact.registration_rejected
A trigger subject of contact.registered has two segments and the topic has four, so it never matches. Nothing warns you. The workflow never starts.
4. Start the workflow on the event
In Cockpit, open Integration Studio → Workflows → New, and add an Event trigger with the subject from step 3, here the approval case:
revenexx.customers.contact.registration_approved
Each approval now starts one run. The trigger drops a repeat delivery of the same event, so a repeated delivery of the same event, matched on the event id, does not start a second run (see Event triggers). The event's own fields sit under data in the trigger payload, and the two events carry different ones:
| Expression | registration_approved | registered |
|---|---|---|
${{ trigger.payload.data.contact_id }} | The contact's id | The contact's id |
${{ trigger.payload.data.organization_id }} | The company, or null for a standalone buyer | The same |
${{ trigger.payload.data.registration_status }} | Not carried | pending or approved |
${{ trigger.payload.data.email }}, name | Not carried | Carried |
${{ trigger.payload.data.organization_created }} | Not carried | true when this registration founded the company |
${{ trigger.payload.id }} | The event's id, not the contact's | The same |
The contact's id is contact_id, not id. ${{ trigger.payload.id }} is the event, and an expression that reaches for it gives every later call an id no contact has.
The approval event carries no email and no name, only ids, the role and who approved. That is why the next step reads the contact rather than building the CRM record from the event. On a workflow for contact.registered, add an If condition first that continues only when registration_status is approved, and ends the run quietly otherwise.
contact.registered has a field self_registered. The registration route in the Customers app refuses a call that names an existing company, so every contact.registered event is a self-registration and the field is always true (as of 2026-11). A colleague a company's administrator invites arrives by invitation and fires neither event, which is why step 8 exists.
contact.registration_approved also fires when a merchant reverses a rejection, with from set to rejected instead of pending. Treat it the same: the contact is let in now, so the CRM should have it.5. Read the contact and the company
Add a Generic API Call node with the Revenexx API credential, resource Contacts, and the operation that gets one contact (GET /v1/customers/contacts/{id}), with the id set to ${{ trigger.payload.data.contact_id }}. See the node catalog. The call as a request:
curl -s "https://api.revenexx.com/v1/customers/contacts/<CONTACT_ID>" \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY"
The answer has what the CRM needs about the person: email, first_name, last_name, phone, job_title, role, locale, organization_id and consent_marketing. It also carries external_id, which the next step checks.
When organization_id is not empty, add a second node for the company (GET /v1/customers/organizations/{id}) with organization_id from the contact. Read name, vat_id, customer_number and status from it. A buyer registered on their own, without a company, has no organization_id: skip this node and create only the person in the CRM. In approval mode the company a registration founds is parked as blocked until approval, and the approval that fired this event un-blocks it, so the read here sees it active.
There is no company event to wait for. The registration creates the company in the same call, and the contact already points at it, so reading the company by the contact's organization_id is how a founded company reaches you. Subscribing to the company's own organization.created instead would give you the company without its contact, and the CRM needs the person on it.
6. Create it in the CRM
First, skip a contact that is already done. Add an If condition that continues only when external_id from step 5 is empty. A run started again, such as a replay from Missed runs, then stops here once step 7 has written the CRM's id.
This guard does not cover a retry. A failed node is retried on its own (three attempts by default) without passing back through this check, and a run can create the contact in the CRM and then fail before step 7 writes the id. So the CRM call must be safe to repeat. Send the revenexx contact id as the key your CRM deduplicates on, and when the CRM already holds a contact under that key, it must answer with success and the id it gave the first time, not create a second record or refuse. If your CRM has no such key, look the contact up by email first, as the CRM's own duplicate check, and create it only when nothing comes back.
Add an HTTP Request node for the account, if the contact has a company, then one for the contact. Map at least:
| From revenexx | Into the CRM |
|---|---|
Company id | A field on the account of your own, such as revenexx_organization_id, and the key the account is found by |
Company name, vat_id | The account's name and VAT ID |
Company customer_number | The ERP customer number, if the CRM keeps one |
Contact id | A field on the contact, such as revenexx_contact_id, and the key the contact is found by |
Contact email, first_name, last_name, phone, job_title | The person's own fields |
Contact role, locale | Whatever the CRM keeps for them |
Contact consent_marketing | Only if the CRM stores consent. Do not default it to true |
Create the account first, so the contact can be attached to it, and keep the id the CRM gives it. Do not write that id back to the revenexx company: the API has no writable field for it (as of 2026-11). The revenexx company id on the account is the join.
On these nodes, set onError to route; step 9 wires what happens next. The node retries three times by default, even with no retry configured, and a request that timed out may still have been created on the CRM side. Until the CRM's duplicate check is in place, set the node's retry to one attempt (maxAttempts: 1) with Max attempts under Retry on failure in the node's Settings tab.
Before you rely on either, send the node a request your CRM rejects in a test run, and check which port the answer leaves on. If a rejection comes out of response rather than error, or your CRM answers 200 with an error in the body, add an If condition after the node that checks the answer and routes a rejection to the same error branch. See Failure handling.
If the CRM's answer carries no contact id, stop before step 7 and route the run to the error branch: an empty external_id joins nothing.
7. Write the CRM's id back
Add a Generic API Call for the contact's update operation, with the contact's id and the CRM's contact id in external_id. The examples use a CRM-style id, CRM-0047; send whatever your CRM answered in step 6. The same call as a request:
curl -s -X PUT "https://api.revenexx.com/v1/customers/contacts/<CONTACT_ID>" \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"external_id":"CRM-0047"}'
The update is partial: send only external_id. Four things to know:
external_idis unique per tenant while it is set. If another contact already carriesCRM-0047, the call answers409withcode: "unique_violation"and nothing is written. That is how a CRM that hands out one record for two revenexx contacts shows up. Check the CRM for a duplicate before you try again. The same409carriescode: "in_trash"when a contact in the trash still holds that id: restore that one or use another id.- It is how each system finds the other's record. From the shop's side, the CRM's id finds the contact with
GET /v1/customers/contacts?external_id=CRM-0047. The filter matches exactly. An unknown filter name is ignored, not refused, so a typo such as?external_ref=answers200with every contact on the tenant. Compare thefilterobject in the answer with what you sent. - Writing it again is allowed. The id is not write-once, so a repeated call with the same value answers
200and a different value replaces the first. The next run's check in step 6 reads the stored id, so a replaced id is the one that counts. - The update publishes
contact.updated. Do not start this workflow on that topic: it would fire on its own write.
external_id is not a login. It does not touch external_user_id, the link to the buyer's sign-in, and a buyer cannot set either one: the shop's buyer-facing routes refuse both as merchant-owned fields, so only a call with your key writes them.
8. Backfill and reconcile
The event covers a registration that reaches the CRM while the workflow is saved and running. It does not cover:
- Buyers who registered before you built the workflow. No event is replayed for them.
- Contacts the shop gets another way. A colleague an administrator invites into a company, and a contact a merchant creates in Cockpit, fire neither
contact.registerednorcontact.registration_approved. - A missed run. A firing while a saved build was still running lands in Missed runs, not in a run. Replay it from there (Missed runs), and let the job below find the rest.
One job covers all three: list the contacts that have no CRM id yet and send each one through steps 5 to 7. Run it once as the backfill, then on a schedule as the reconcile. Build it as a second workflow with a Schedule trigger (Schedule triggers), reusing the nodes from steps 5 to 7, or as a script on your side.
Read the contacts a page at a time:
curl -s "https://api.revenexx.com/v1/customers/contacts?registration_status=approved&order=created_at.asc&limit=200&offset=0" \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
| jq '{page, todo: [.items[] | select(.external_id == null) | .id]}'
Four things shape the job:
- Pages.
limitis 50 by default and 200 at most. The answer carriespage.totalandpage.hasMore. RaiseoffsetbylimituntilhasMoreis false. Sorting bycreated_at.asckeeps new contacts at the end, so a contact added while you page does not shift the ones you have not read. A contact moved to the trash while you page does shift later offsets by one, so one contact can be skipped. The next run picks it up. - No "changed since" filter. The list has no range filter:
updated_atandcreated_atmatch one exact timestamp only. So you cannot ask for the contacts changed since the last run. Page through all approved contacts and act on the ones without a CRM id. The sort and the check onexternal_idare what make a nightly run cheap, because a contact that already has its id costs one row in a page and no calls. - No filter for "has no
external_id". The filter onexternal_idmatches an exact value, so you cannot ask for the empty ones. Select them on your side, as thejqexpression above does. - Only approved contacts.
registration_status=approvedleaves out pending and rejected applications. A contact the shop created without a registration isapprovedas well, so a merchant who adds a customer by hand in Cockpit also reaches the CRM. If the CRM should not get those, filter further, for example onlead_source, or decide here that it should.
Each run sends a contact on the same path as the event did, so a contact the event already sent has an id and is skipped. The CRM's duplicate check from step 6 is what covers a contact created in the CRM by hand before the first run: when it finds the email, it answers with that record's id and step 7 joins the two.
The job also finds the contact whose step 7 failed after the CRM call, which is the one case the event path cannot repair on its own.
9. Make a failure visible
An event-driven workflow fails with nobody watching. Wire the error ports of the CRM nodes from step 6 and the write-back from step 7 to something your team reads, such as a Send Email node or a call into your ticketing system, and end that branch with a Stop and Error node that names the contact:
CRM rejected contact ${{ trigger.payload.data.contact_id }}, see the run's steps
Without the Stop and Error node the run finishes green after the email, and the run list shows a success. With it, the run is marked failed with a reason someone can act on. After the cause is fixed, run the contact again. If the run failed at or after the CRM call, the CRM may already hold the contact, and its duplicate check from step 6 answers with the existing id, so the second run writes it back instead of creating a second record. A failure at step 7 matters most: the contact is in the CRM but still has no external_id, and until it is written the reconcile job will offer it again.
Check Integration Studio → Runs → Missed runs after every save. A workflow only runs while its build is ready.
Troubleshooting
- The workflow never starts. The trigger subject is
contact.registeredor has a typo. It has to be the full topic from the catalog, such asrevenexx.customers.contact.registration_approved(step 3). Then look in Missed runs. - A pending application reaches the CRM. The workflow is on
contact.registeredwithout the status check from step 4, or it uses the approval topic on a market that is open. Check the tenant'sregistration_modefor each market. - The run fails at step 5 with an id that no contact has. The expression reads
${{ trigger.payload.id }}, which is the event's id. Use${{ trigger.payload.data.contact_id }}. - An authorization error on one node only. The key lacks that node's scope. The credential test does not check scopes.
409at step 7. Another contact holds thatexternal_id, in the shop or in its trash. Look it up with?external_id=and decide which CRM record is the real one, then restore the trashed holder or use another id.400withcode: "field_immutable". The update body included anorganization_idthat differs from the contact's. A contact stays in the company it was created in. Send onlyexternal_id.- The reconcile job creates the same contact twice. The CRM's duplicate check from step 6 is missing or compares a different key than the first run sent. Fix the check before the next run.
- The list answers
200with every contact. A filter name is wrong, and the API ignored it. Comparefilterin the answer with the parameters you sent.
Where to go next
Start with Choose which system owns each entity, to settle master and join key for the fields both sides edit before the first run.
- Contacts — registration, approval and the contact record in full.
- Storefront accounts — how a buyer registers and what
registration_modechanges. - Event triggers — subjects, wildcards and the payload envelope.
- Hand an order to the ERP — the same outbound pattern for orders.
- Receive platform events at your own endpoint — the webhook route, if you would rather host the receiver.