A form on your own site creating contacts in the CRM — which endpoint, which key, which fields, and what the CRM deliberately does not do for you.
A newsletter form, a gated download, a contact form: the sign-up should land as a contact in your workspace. It can, in a single call — but not through a webhook, and not with your own API key. This page describes the route we recommend, and names the three things that deliberately do not exist for it.
Webhooks point out of the CRM. You subscribe to events like contact.created and the CRM calls your endpoint. There is no endpoint a form could post to without authenticating — that would be an open write surface onto your records, which is why it does not exist.
So your server calls the CRM, not the other way round: POST /api/v1/contacts with the x-api-key header. The form itself never calls the CRM directly — the key belongs on your server and must not reach the browser.
Do not use your personal key. An API key inherits the permissions of its user, and per-key permissions do not exist. A key cut from your account can do everything you can — on a web server that is more trust than a sign-up form needs.
Create a role of its own under Company → Roles instead, invite a user for it, and generate the key as that user.
Know in advance how narrow the role can get, because one limit is surprising.
The role editor grants create, edit and delete separately per area, plus read access (none / own / all). A create-only role is therefore a matter of ticking one box.
And read access none does not work. Someone who may not read everything has to assign every record they create to themselves — otherwise they would create a record their own author can no longer see. Creating then fails with 400 and "You must assign yourself to this item" at the userIds field. For a form that is the wrong direction: the lead should belong to sales, not to the web server.
So the narrowest role a form can carry is:
Then, signed in as that user: Profile → API & Connectors → Add. The key is shown once, belongs in an environment variable on your server, and not in your repository. Details under API keys.
What that is worth in an incident, and what it is not: a key cut this way sees no deals, organizations, invoices or mailboxes, and you can delete it without anything else stopping. It does read every contact, though, and it can update and delete them too, because the editor does not separate those — your whole contact base hangs on the key sitting on your web server. If that is more than you want to expose, put the write behind a small service of your own that only lets creates through.
curl -X POST https://crm.virtual-marketer.de/api/v1/contacts \
-H "x-api-key: $VM_CRM_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"firstName": "Jana",
"lastName": "Beck",
"notes": "Requested the media kit on 2026-09-17, form /media-kit",
"identifiers": [
{ "provider": "mail", "value": "jana.beck@example.de" }
]
}'201 with the created contact, 400 with a readable validation error.
What to know about it:
firstName must not be blank; lastName is required and may be empty. A form with a single "Name" field writes the whole name into firstName and "" into lastName — that is valid, and more honest than guessing a surname.identifiers as { provider, value }, with provider one of mail, google, outlook, whatsapp. This is not just a different spelling: when the same person later writes to a connected mailbox or sends a WhatsApp message, the inbox attaches it to exactly this contact. An address that only sits in notes does not do that.400, and then nothing is created at all. Validate the format on your side before sending, or you lose the sign-up entirely.customFieldValues as { columnId, value }. The ids come from GET /api/v1/contacts/configuration; for a select column, value is the option's UUID, not its label. Select columns that have a default option are filled by the CRM on create, so a lead lands in the first pipeline stage without your form sending it.The CRM deduplicates on its own, but only by channel: an email address or a WhatsApp number can hang off exactly one contact. A second POST with an address that is already linked is therefore a 400:
✖ This channel is already linked to another contact.
→ at identifiers[0].valueThat is not an outage, it is the answer "we know this one already". Handle exactly this case separately: do not retry it, do not report it as a failure — the person is already in the CRM. Any other 400 is a real error in your data; a 5xx or a timeout is worth retrying.
What follows from that:
POST /api/v1/contacts/search with { "searchTerm": "jana.beck@example.de" }.contact.created with a webhook and trigger it there.