Your first integration
This walkthrough builds a small integration end to end with curl: it defines a custom field and a tag, creates or updates a contact that uses them, creates a template from HTML, subscribes a webhook to the result, sends the template to the contact, and receives the webhook. The examples use a fictional organisation, the Birchwood Community Garden, which assigns garden plots to its members. Each step links to the full reference for the endpoint it uses.
Before You Start
Section titled “Before You Start”- Credentials. You need your API ID and API Key, and the API must be enabled for your account; see Authentication. The examples read the credentials from the
API_IDandAPI_KEYshell variables set up in Make a Request. - Send prerequisites. Step 5 uses the Send API, which needs the Transactional API preference set in Envoke, a sender identity, and credits. See Before Your First Send.
- Real data. Every request in this walkthrough changes your account, and step 5 sends a real email. Use an email address you control in place of
ama.osei@example.org. - Limits. Every request counts towards your account’s API limits.
Step 1: Create the Custom Field and the Tag
Section titled “Step 1: Create the Custom Field and the Tag”Custom fields and tags are defined once for the account, then set on each contact through the Contact API. Skip this step for any that already exist; to check, filter by name with GET https://e1.envoke.com/v2/customfields?filter%5Bname%5D=Plot_Number and GET https://e1.envoke.com/v2/tags?filter%5Bname%5D=Volunteer, which return [] when there is no match.
Create a custom field to hold each member’s plot number:
curl -X POST -gs \ -u "$API_ID:$API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "Plot_Number", "field_type": "text", "description": "Garden plot assigned to the member"}' \ "https://e1.envoke.com/v2/customfields"{ "success": true, "result_text": "Created", "result_data": { "id": "9201", "name": "Plot_Number", "description": "Garden plot assigned to the member", "field_type": "text" }}A custom field name can contain only letters, digits, and underscores. A name that is already used fails with A custom field named 'Plot_Number' already exists. See Custom Field API - Insert.
Create a tag for members who volunteer:
curl -X POST -gs \ -u "$API_ID:$API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "Volunteer"}' \ "https://e1.envoke.com/v2/tags"{ "success": true, "result_text": "Created", "result_data": { "id": "9101", "name": "Volunteer", "group": "" }}(result_data shortened; the response contains every tag field.)
See Tag API - Insert. The Contact API refers to the custom field and the tag by name, not by these IDs.
Step 2: Create or Update the Contact
Section titled “Step 2: Create or Update the Contact”Create the contact, or update it if it already exists, with one upsert request. upsert=1 turns a create into an update when a contact already matches, and key_field=remote_id matches on your own identifier instead of the email address:
curl -X POST -gs \ -u "$API_ID:$API_KEY" \ -H "Content-Type: application/json" \ -d '{"email": "ama.osei@example.org", "remote_id": "BCG-0417", "first_name": "Ama", "last_name": "Osei", "consent_status": "Express", "consent_description": "Signed the member volunteer form, 2026-04-18", "custom_fields": {"Plot_Number": "B-14"}, "interests": ["Volunteer"]}' \ "https://e1.envoke.com/v1/contacts?upsert=1&key_field=remote_id"The first time, no contact has the remote ID BCG-0417, so the contact is created and returned in full:
{ "success": true, "result_text": "The contact was successfully created.", "result_data": { "id": "9001", "external_id": "5c1e9a3f7b2d4e6a8c0f1b3d5e7a9c2e", "date_created": "2026-10-28T13:05:12+00:00", "remote_id": "BCG-0417", "first_name": "Ama", "last_name": "Osei", "email": "ama.osei@example.org", "consent_status": "Express", "consent_description": "Signed the member volunteer form, 2026-04-18", "consent_effective_date": "2026-10-28T13:05:12+00:00", "consent_expiry": "", "custom_fields": { "Plot_Number": "B-14" }, "interests": ["Volunteer"], "autoresponders": [] }}(result_data shortened; the response contains every field, with "" for those not set.)
Sending the same request again matches the contact by remote_id and updates it: result_text is The contact was successfully updated. and result_data is the full, updated contact. The Send API sends only to existing contacts whose consent allows sending, which is why the contact is given Express consent here; see Consent fields.
Points to know about this request:
- The parameter is
key_field, notkeyfield.?keyfield=remote_idis ignored without an error, so the request matches byemailinstead. emailis required even whenkey_field=remote_idis used. If your account checks email addresses for duplicates, an email that belongs to another contact fails withcontact with email = ama.osei@example.org already exists.- More than one match is an error, even with
upsert:Multiple contacts already exist with 'remote_id' value 'BCG-0417'. (IDs: 9001, 9002). - An upsert that updates differs from a create. Empty values are ignored, a missing
consent_statusleaves the contact’s current status unchanged, and a consent downgrade is recorded in the contact’s history without becoming current unlessconsent_force_downgradeistrue. See Upsert: update when the contact exists. interestsas a list only adds. To remove the tag, send the object form,{"Volunteer": "Unset"}.- Custom field keys. On write,
custom_fieldskeys match the field name ignoring case, with a space and an underscore treated as the same, so"plot number"writes toPlot_Number. In read filters the name is case-sensitive:filter[custom_fields.Plot_Number]=B-14. Seecustom_fields. - Warnings are prose. An unrecognised
countryorlanguagevalue does not fail the request; a warning is appended toresult_text, after a second period on an upsert that updates:The contact was successfully updated.. 'Narnia' is not a valid value for country; field will not be updated. See Warnings. - Errors come in three shapes, depending on which check fails: the validation envelope with
result_data.errors, a field-validation envelope withresult_data.fieldskeyed by database column, and a plain envelope for an empty or unreadable body. See Contact API errors.
Step 3: Create a Template
Section titled “Step 3: Create a Template”Create a template from your own HTML:
curl -X POST -gs \ -u "$API_ID:$API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "Plot Assignment", "html": "<html><body><p>Hi {@first_name},</p><p>Your plot for the season is {PLOT_NUMBER}. The garden opens on Saturday at 9:00 a.m.</p></body></html>"}' \ "https://e1.envoke.com/v2/templates"{ "success": true, "result_text": "Created", "result_data": { "id": "9301", "external_id": "8d2f4b6a1c3e5f7a9b0d2c4e6f8a1b3d", "preview_url": "https://engage.envoke.com/m/b4d6f8a0c2e4/m/8d2f4b6a1c3e5f7a9b0d2c4e6f8a1b3d", "name": "Plot Assignment", "status": "published", "language": "en", "group": "", "create_time": "2026-10-28 09:07:40", "modified_time": "2026-10-28 09:07:40" }}The HTML holds two placeholders: {@first_name} is an Envoke merge field, filled in from the contact, and {PLOT_NUMBER} is your own text, filled in by the send request in step 5. The HTML is stored as sent, up to 200 kB; it is not returned in this response, only by GET https://e1.envoke.com/v2/templates/9301?include_html=1. create_time and modified_time are in your account’s time zone. See Template API - Insert.
The HTML has no unsubscribe link of its own, so the Send API adds Envoke’s standard unsubscribe footer to the end of the email (see Tracking and merge fields).
Step 4: Subscribe to the Result
Section titled “Step 4: Subscribe to the Result”Subscribe a webhook before sending, so that your endpoint hears about the email. An email_send event fires when an email is sent to a contact:
curl -X POST -gs \ -u "$API_ID:$API_KEY" \ -H "Content-Type: application/json" \ -d '{"type": "email_send", "url": "https://hooks.example.org/envoke/email-send", "filter": {"contact_remote_id": "BCG-0417"}}' \ "https://e1.envoke.com/v1/hooks.json"Envoke returns HTTP 201 with the subscription’s ID:
{"id": "4f6a8c0e2b4d6f8a1c3e5b7d9f1a3c5e"}The filter limits deliveries to events for the contact with the remote ID BCG-0417. Without it, your endpoint receives email_send events for all of your account’s contacts. A filter key must be a field of the delivered payload, or every event is skipped.
Subscriptions cannot be changed; delete one and create another instead. Creating a subscription with the same type and url as an existing one fails with HTTP 400, A webhook subscription with this type and URL already exists, and the existing subscription’s ID in result_data.id. To hear about failed emails, subscribe to email_bounce the same way. See Hooks API - Create and filter.
Step 5: Send the Template to One Address
Section titled “Step 5: Send the Template to One Address”Send the template to the contact with the Send API:
curl -X POST -gs \ -u "$API_ID:$API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "Plot Assignment BCG-0417 2026-10-28", "subject": "Your garden plot is ready", "template_id": 9301, "enable_merge_fields": true, "recipients": [{"email": "ama.osei@example.org", "replace": {"{PLOT_NUMBER}": "B-14"}}]}' \ "https://e1.envoke.com/v1/send"{ "success": true, "result_text": "Message is sending", "result_data": { "id": 9401, "new_contacts_created": 0, "summary": { "total_selected": 1, "emails_to_be_sent": 1, "excluded_unsubscribed_contacts": 0, "excluded_invalid_emails": 0, "excluded_bounced_emails": 0, "excluded_unverified_emails": 0 }, "recipients": [ { "id": 9001, "email": "ama.osei@example.org", "emails_to_be_sent": true } ] }}The request creates a message from the template’s HTML and queues the email at once. enable_merge_fields fills in {@first_name}, and the recipient’s replace value fills in {PLOT_NUMBER} as the email is delivered. The message is sent from your account’s default sender identity, filed in the API Messages group, and sent without open or click tracking. result_data.id is the new message’s ID, as an integer; the Message API returns the same message with id "9401". See Send API - Send.
Points to know about this request:
- Check each recipient, not only
success.successistruewhen at least one recipient is sent the message. A recipient who is not sent it is listed with the reason, such asexcluded_unsubscribed_contacts, and counted insummary(see Response). When no recipient can be sent it, the response is HTTP400withMessage not sent, there are no valid recipientsand no message is created. - An address that matches no contact becomes a contact. It is created with the consent from your Transactional API preference and sent the message. Recipients are not de-duplicated, and an address in a different letter case from the stored one is treated as a different recipient. A request refused for credits (
Message not sent, the account is out of creditsorMessage not sent, the account has not upgraded) has already created contacts for any addresses that matched none. - The subject is not copied from the template, so
subjectis required, at least 6 characters. replacevalues are inserted as written, without escaping, so a value can contain HTML. An invalidreplaceexcludes that recipient asexcluded_invalid_replace_datainstead of failing the request (seereplace).- Do not repeat a send blindly. The Send API has no idempotency key: each request that succeeds creates a new message and sends it, so repeating a request whose response you did not receive can send the email twice. Give each send a unique
name, and look it up before sending again:GET https://e1.envoke.com/v2/messages?filter%5Bname%5D=Plot%20Assignment%20BCG-0417%202026-10-28returns[]when no message has that name. A message with that name means the earlier request created it; read itsstatusbefore deciding to send again. - There is no test mode. Every successful request sends real email.
To follow the message itself, read it with GET https://e1.envoke.com/v2/messages/9401: its status is Sending, then Sent once every email has been handed off for delivery.
Step 6: Receive the Webhook
Section titled “Step 6: Receive the Webhook”When the email is sent, Envoke sends an HTTP POST to the subscription’s URL with a JSON array of events:
[ { "activity_type": "email_send", "timestamp": 1793193131, "message_id": "9401", "message_external_id": "e1a3c5b7d9f1a3c5e7b9d1f3a5c7e9b1", "message_name": "Plot Assignment BCG-0417 2026-10-28", "message_subject": "Your garden plot is ready", "sender": "Birchwood Community Garden <hello@example.org>", "contact_id": "9001", "contact_external_id": "5c1e9a3f7b2d4e6a8c0f1b3d5e7a9c2e", "contact_email": "ama.osei@example.org", "contact_remote_id": "BCG-0417" }]message_id matches the id from step 5, and contact_remote_id is the identifier you gave the contact in step 2. message_subject is the stored subject, with any merge fields not filled in. The fields of every event type are in Hooks API - Webhook Payloads.
Your endpoint should handle these rules:
- Read the body as an array.
email_sendevents are batched, so one POST can carry several events (see Delivery Format). - Return a
2xxstatus. On any other status, Envoke retries once after 10 minutes. A410response removes the subscription. - Deliveries carry no signature or secret. The request has no header that identifies it as coming from Envoke.
- Deliveries count towards your API limits. Each event Envoke delivers counts as one request towards your account’s API limits, alongside the requests you make.
Without "track_recipient_activity": true, a Send API message has no open-tracking image and its links are not rewritten to record clicks, so it produces no email_open or email_click events.
Next Steps
Section titled “Next Steps”- Which API do I use? compares the Send API with the Message API, which sends to a tag, subscription, or segment with a draft, an estimate, a schedule, and a cancel.
- Tag API - Add and Remove Contacts adds up to 1,000 contacts to a tag in one request.
- Message Stats returns the sends, opens, clicks, and bounces of the message from step 5.
Note: The curl examples above are formatted for readability. All requests must use properly encoded URLs.
Full reference, OpenAPI description, and Postman collection: developers.envoke.com.

