The script is served from the domain root, not from
/api/v1. The subscribe
endpoint is the only one of the two that takes credentials.Subscribe
X-Sender-Tenant and X-API-Key header pair. X-Sender-Tenant accepts your workspace slug or its UUID.
The endpoint is CORS enabled for any origin, so a browser can post to it directly. A preflight OPTIONS returns 204. The allowed request headers are Content-Type, X-Sender-Tenant and X-API-Key.
Identity
A submission must carry an email address, a phone number, or both. Neither field is required on its own.- The email is trimmed and lowercased.
- The phone is normalised to E.164. Israeli local formats are accepted (
0501234567and501234567both become+972501234567), spaces, dashes, dots and brackets are stripped, and a leading00is read as+. A number that cannot be normalised is rejected rather than stored raw.
Body
string
Subscriber email address. Max 320 characters. Required only if no
phone is
sent.string
Subscriber phone number. Max 40 characters. Required only if no
email is
sent.boolean
default:"true"
Whether this submission is an opt in. Omit it or send
true to subscribe the
channels the submission carries. Send false to record the contact with no
consent change on either channel. Must be a real boolean, not the string
"true".string
default:"embedded_form"
Which surface collected the submission. One of
embedded_form or
wordpress_form. See How source changes behaviour.string
Your own label for which form this came from. Max 120 characters. Stored on
the contact as the custom property
embeddedFormId. It is client supplied
and is not a security boundary.object
Extra field values, keyed by contact property. See
How fields map.
string
The page URL the form was submitted from. Max 2048 characters. Accepted and
validated, and not written to the contact record today.
400. Send only the fields above.
Response
200 OK with the standard envelope.
boolean
Always
true on a 200.string
UUID of the contact that was created or matched.
boolean
true when this submission created the contact, false when it matched an
existing one.boolean
true when the contact is opted in on at least one channel after this
submission. A subscribe: false submission that creates a contact returns
false. A subscribe: false submission against a contact who was already
opted in still returns true, because it describes the contact, not the
change.Phone only
A phone number with no email address is a complete submission. The contact is created with SMS consent and no email address.Capture without opting in
A contact form or a support form collects an address without asking for marketing permission. Sendsubscribe: false and the contact is stored with both channels left at never_subscribed, no consent log entry, and no list_joined enrolment.
Consent
Consent is recorded per channel, and only for the channels the submission actually carries.
Every consent change writes a consent log entry carrying the channel, the source, the caller IP address and the
User-Agent of the request. That log is the evidence record for the opt in, which is why the endpoint reads the address and agent from the request itself rather than from the body.
On an existing contact, a channel already at subscribed is left alone and produces no duplicate log entry. A subscribing submission also lifts any suppression entry for that address or number on that channel, so a re-subscribe through a form restores sending.
Flow enrolment
Two flow triggers fire after a successful submission, both best effort. A failure to enrol is logged and never fails the request.contact_createdfires when the submission created a new contact.list_joinedfires when the submission actually changed consent on at least one channel. It does not fire for asubscribe: falsesubmission, and it does not fire when the contact was already opted in on every channel the submission carried.
How source changes behaviour
source sets three things at once.
wordpress_form skips the allowlist because the WordPress plugin posts from your server, where there is no browser Origin header to check. A browser embed should stay on embedded_form so the allowlist still applies.
Origin allowlist
Whensource is embedded_form, the request Origin header is checked against the workspace allowlist configured in Settings → Embedded Forms.
- An empty allowlist accepts any origin. This is the default, so existing embeds keep working until you decide to restrict them.
- With entries configured, a request whose
Origindoes not match, and a request with noOriginheader at all, are rejected with403. - An entry written as a bare host (
acme.com) matches that host on eitherhttporhttps. An entry written as a full origin (https://acme.com) matches that scheme and host exactly. - Entries are matched case insensitively. Up to 20 entries are accepted.
How fields map
Every key infields is resolved against the contact property catalog, by property key, display label, or a known alias. firstName, first name and fname all reach the same property.
Keys that fill a contact column
Keys that fill a contact column
firstName, lastName, phone, timezone and language (stored as the
contact locale) are written to the contact record itself.Keys stored as custom properties
Keys stored as custom properties
Any other catalog property, for example
city, zip, address1,
country or dateOfBirth, is stored in the contact’s custom properties
under its catalog key. formId is stored the same way, as
embeddedFormId.Keys that are ignored
Keys that are ignored
A key that matches no catalog property is dropped. Computed properties,
the ones Senderz derives itself, are also ignored. An empty string value is
skipped for every key.
Matching an existing contact
The endpoint matches on the submitted email address, then on the submitted phone number. When it finds an existing contact it fills gaps rather than overwriting.- An existing email address is never overwritten. A submitted
emailis written only when the contact has none, and anemailsent insidefieldsis ignored entirely for an existing contact. - An existing phone number is never overwritten. A submitted phone is written only when the contact has none.
- The other contact columns
fieldscan reach,firstName,lastName,timezoneandlanguage, are overwritten by the submitted value. Send them only when the form really asked for them. - Custom properties from
fieldsare merged into the existing ones, and a key present in the submission replaces its previous value.
Errors
Checks run in this order: body validation, credentials, feature access, identity, then the origin allowlist. The first failure is returned, so a malformed phone number is reported before an origin problem.
A coded error returns the machine readable
code alongside the message, so you can branch on it without parsing English:
A missing email is no longer a validation failure. If you previously matched
on the message
email must be an email to detect an empty submission, switch
to the contact_identifier_required code.The form script
Content-Type: application/javascript; charset=utf-8 and Cache-Control: public, max-age=300, so a change reaches a browser within five minutes.
form[data-senderz-form] on the page, including forms added later, and posts each submission to the subscribe endpoint.
name="email"becomesemail.name="phone"orname="sms"becomesphone. Every other named input goes intofields.data-form-idbecomesformId, and the current page URL becomessourceUrl.data-success-messageanddata-error-messageoverride the text shown after a submission.- A successful submission fires a bubbling
senderz:subscribedevent on the form.
The script derives the API host from its own
src, so it must be loaded from
https://api.senderz.app. It also requires an email address before it will
submit, even though the API itself accepts a phone number on its own. For a
phone only form, post to the endpoint from your own code.
