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.
- Tools. The examples use
curl, and step 3 also usesjq1.6 or later to send an HTML file as JSON.
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. Look each one up before creating it:
curl -X GET -gs -u "$API_ID:$API_KEY" "https://e1.envoke.com/v2/customfields?filter%5Bname%5D=Plot_Number&fields=id,name"curl -X GET -gs -u "$API_ID:$API_KEY" "https://e1.envoke.com/v2/tags?filter%5Bname%5D=Volunteer&fields=id,name"Each returns [] when nothing has that name, or an array of the matches with their IDs. filter[name] matches the whole name; for custom fields it ignores letter case, so the lookup can return plot_number. Create only what is missing, and from then on use each name exactly as the API returns it.
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, in any letter case, 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. Points to know about names:
- Tag names are not unique. The Tag API does not refuse a name that is already used, so creating
Volunteeragain gives you a second tag with the same name. That is why the lookup comes first. If the lookup returns more than one tag, choose one and use its ID. - Refer to the tag by its ID. The Contact API resolves the names in
interestsmore loosely thanfilter[name]: it ignores case, treats a space and an underscore as the same, and looks at every kind of list in your account, including subscriptions, import lists, and poll answers, not only tags. A name can therefore pick an item other than your tag. Step 2 sets the tag by the ID returned here,9101, which always means this tag. - Refer to the custom field by its name. The Contact API does not accept a custom field’s ID;
custom_fieldsis keyed by name (seecustom_fields). Custom field names are unique in the account, whatever their letter case.
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": {"9101": "Set"}}' \ "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 contact is given Express consent here because the Send API sends to an existing contact only when their consent allows sending (see Consent fields).
Do not send consent on every sync. Sending consent_status Express for a contact whose status is Revoked, because they unsubscribed, makes Express their current status again: the contact is opted back in, and the response does not say so. Send consent only when you have new consent from the person, such as a form they have just signed. When a sync runs from your own records, read the contact first and leave the consent fields out for a contact that already exists:
curl -X GET -gs \ -u "$API_ID:$API_KEY" \ "https://e1.envoke.com/v1/contacts?filter%5Bremote_id%5D=BCG-0417&fields=id,consent_status"The response is [] when no contact has the remote ID; create the contact with its consent, as above. Otherwise it is an array with the contact’s id and current consent_status; send the upsert without consent_status and consent_description, which leaves the contact’s consent unchanged. Leave out limit=1 here: with it, a match is returned as a bare object instead of an array.
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. That contact usually has noremote_idyet, for example because the person signed up through a form. Look it up withGET https://e1.envoke.com/v1/contacts?filter%5Bemail%5D=ama.osei%40example.org&fields=id,email,remote_id; if it is the same person, give it your identifier withPATCH https://e1.envoke.com/v1/contacts?key_field=emailand the body{"email": "ama.osei@example.org", "remote_id": "BCG-0417"}, then repeat the upsert, which now updates it. See Email already used by another contact.- 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. A consent change that is not made current, or is blocked (for example for a contact who reported spam), is not reported in the response. See Upsert: update when the contact exists and When a change takes effect. interestsadds unless told otherwise. The object form{"9101": "Set"}adds the tag with ID9101;{"9101": "Unset"}removes it. The list form,["Volunteer"], also works, by name, and only adds. In a read,interestslists names.- 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”Write the email as HTML that Envoke’s drag-and-drop editor can open, so that people in your team can change the wording in Envoke later without touching your code. Save it as plot-assignment.html:
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en"><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><meta name="TEMPLATE_VERSION" content="230609"><title>{@msg-subject}</title><style type="text/css" id="envoke-styles"> .inner-column { line-height: 1.3; } .image-edit { line-height: 0; } .image-edit.image-empty { display: none; } a { text-decoration: none; } u a { text-decoration: underline; } p, h1, h2, h3, h4, li { margin: 0; padding: 0; margin-bottom: 0; } h1, h2, h3, h4 { font-weight: normal; } ul, ol { margin-top: 0; margin-bottom: 0; } .desktop-hide { display:none !important; } @media screen and (max-width: 540px) { table { width:100% !important; } td.block { width:100% !important; display:block !important; } img.responsive-width { width:100% !important; height:auto !important; margin:0 !important; } table.nvkButtonTable { width: auto !important; } .mobile-hide { display:none !important; } .desktop-hide { display:block !important; } .block-inner .inner-column { padding-left: 20px !important; padding-right: 20px !important; } .block-inner .inner-column.mobile-full-width { padding-left: 0 !important; padding-right: 0 !important; } p, li, p span, li span { font-size:16px !important; } .column-table-rows .inner-column { height: auto !important; } } </style></head><body class="nvkbody" style="padding: 0px; margin: 0px; background-color: #f4f4f4; color: #333333; font-family: Arial, Helvetica, sans-serif; font-size: 15px;"><div class="nvkbody wrapper" style="width: 100%; background-color: #f4f4f4; color: #333333; font-family: Arial, Helvetica, sans-serif; font-size: 15px;"><center><table class="layout-table" cellpadding="0" cellspacing="0" width="100%"><tr><td align="center" valign="top"><table class="layout-table" align="center" cellpadding="0" cellspacing="0" width="680"><tr><td align="center" valign="top">
<div class="block-outer"> <table class="layout-table outer-table" align="center" cellpadding="0" cellspacing="0" width="660"> <tr> <td class="block-inner-background block layout-column outer-column" style="background-color: #ffffff; color: #333333;" align="center" valign="top"> <table class="layout-table" align="center" cellpadding="0" cellspacing="0" width="660"> <tr> <td class="block-inner-background" style="background-color: #ffffff; color: #333333;" align="left" valign="top">
<div class="block-inner"> <table class="inner-table" align="center" cellpadding="0" cellspacing="0" width="660"> <tr> <td class="inner-column" style="padding: 30px 20px;" align="left" valign="top"> <div class="content-edit" rel="#bck-overlay"> <p>Hi {fname},</p> <p> </p> <p>Your plot for the season is {@customfield-Plot_Number}. The garden opens on Saturday at 9:00 a.m.</p> </div> </td> </tr> </table> </div>
<div class="block-inner"> <table class="inner-table" align="center" cellpadding="0" cellspacing="0" width="660"> <tr> <td class="inner-column" style="padding: 20px; background-color: #f4f4f4;" align="left" valign="top"> <div class="content-edit" rel="#bck-overlay"> <p style="text-align: center;"><span style="font-size: 13px;">{@brand-address}</span></p> <p style="text-align: center;"><span style="font-size: 13px;"><u><a href="{@pref-0}">Update your email preferences or unsubscribe</a></u></span></p> </div> </td> </tr> </table> </div>
</td> </tr> </table> </td> </tr> </table></div>
</td></tr></table></td></tr></table></center></div></body></html>Create a template from the file. jq reads the file and builds the JSON body, escaping the HTML; any JSON library does the same:
jq -n --rawfile html plot-assignment.html '{name: "Plot Assignment", html: $html}' | \ curl -X POST -gs \ -u "$API_ID:$API_KEY" \ -H "Content-Type: application/json" \ -d @- \ "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 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. Template names are not checked for uniqueness. See Template API - Insert.
What makes the HTML editable, and what it holds:
- The editor’s structure. The
TEMPLATE_VERSIONmeta tag makes the template open in the drag-and-drop editor, and theblock-outer,block-inner, andcontent-editclasses mark the section, the two rows, and the regions a user can edit. With the meta tag but without these classes, the template opens in the editor with nothing that can be edited. Making HTML Editable explains the structure and has a complete template with an image and a button; the long<style>line is Envoke’s Style Block, copied as it is. - Merge tags.
{fname}is the contact’s first name,{@brand-address}your account’s postal address, and{@pref-0}the link to the contact’s email preferences page, all filled in by Envoke when the email is sent (see Merge Tags). Because the HTML has its own{@pref-0}link, Envoke does not add its standard unsubscribe footer (see The Footer). - The custom field.
{@customfield-Plot_Number}is replaced with the contact’sPlot_Numbervalue from step 2, read when the email is sent. The name aftercustomfield-must match the custom field’s name exactly, including its capitalisation:{@customfield-plot_number}would be replaced with nothing (see Custom 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. Create one subscription per event type for the whole account, with no filter, and let your endpoint pick out the events it cares about. An email_send event fires when Envoke hands an email to its sending server for delivery 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/3f9c1e7a5b2d4c6e8a0f1b3d5e7a9c2b/email-activity"}' \ "https://e1.envoke.com/v1/hooks.json"The long random part of the URL’s path is a secret shared only by Envoke and your endpoint, because deliveries carry no signature (see step 6). Envoke returns HTTP 201 with the subscription’s ID:
{"id": "4f6a8c0e2b4d6f8a1c3e5b7d9f1a3c5e"}Your endpoint now receives an email_send event for every email your account sends, from the Send API and from Envoke itself. It routes each event by contact_remote_id (here BCG-0417), or by message_id, the message ID that the send request returns in step 5.
Why not one filtered subscription per contact. A subscription can have a filter, such as {"contact_remote_id": "BCG-0417"}, but a subscription is unique by type and url, whatever its filter. A second subscription for another contact on the same URL fails with HTTP 400, A webhook subscription with this type and URL already exists, and the first subscription’s ID in result_data.id, so the second contact’s events never arrive. Use a filter to narrow all of the account’s events, such as {"bounce_type": "hard"} on email_bounce, not to follow individual contacts.
Subscriptions cannot be changed; delete one and create another instead. Every delivered event counts towards your API limits, so subscribe only to the types you use. See Hooks API - Create and filter.
Sent is not delivered. email_send means the email has left Envoke, not that it reached the inbox: the recipient’s mail server can still refuse it, and that fires an email_bounce event, sometimes well after the send. The email_delivery type, for the recipient’s server accepting the email, is reserved for an upcoming release; a subscription to it is accepted but receives no events yet. Until then, subscribe to email_bounce as well, the same way and on the same URL (your endpoint tells the types apart by activity_type), and treat an email as delivered when it has an email_send event and no email_bounce event. The Reports API counts delivered the same way, as sends minus bounces.
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": ["ama.osei@example.org"]}' \ "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. Envoke fills in the merge tags as each email is sent, so the plot number comes from the contact record and does not need to be sent again here. enable_merge_fields is needed for {@customfield-Plot_Number} and {@brand-address}; {fname} and {@pref-0} are filled in either way. 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. The Send API does not need an existing contact: an address that matches none is created as a contact, 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. - Custom field values are read at send time. Each email shows the value stored on the contact when it is sent, and a contact with no value gets nothing in its place. Update the contact before sending, as in step 2. For text that belongs to one send only and is not kept on the contact, a recipient can carry its own
replacevalues instead. - Do not repeat a send blindly. The Send API has no idempotency key: each request that succeeds creates a new message and sends it. A
429response was refused before anything happened and can be sent again after a wait. ButInternal error; please contact Envoke support,Invalid request, a5xxstatus, or no response at all can leave a message that already exists. Give each send a uniquename, 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; Retrying a request shows how to find out who it reached before sending to anyone 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. Like email_send, Sent does not mean delivered.
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 is the id from step 5, as a string, and contact_remote_id is the identifier you gave the contact in step 2 ("" for a contact without one). 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 (see Delivery Format):
- Read the body as an array.
email_sendevents are batched, so one POST can carry many events, for many contacts and for messages your integration did not send. Route each one bycontact_remote_idormessage_id, and ignore the rest. - Return a status below
400within 60 seconds. Anything else, or no answer, is retried once after 10 minutes, and then the events are dropped. A410response removes the subscription. - Expect repeats and late arrivals. A retry delivers the same events again, with the same
timestamp, and events are not guaranteed to arrive in order. Email events have no event ID: treat two events with the sameactivity_type,message_id,contact_id, andtimestampas one, and order events bytimestamp. - Deliveries carry no signature or secret. The request has no header that identifies it as coming from Envoke, so anyone who learns the URL can post to it. Keep the random secret in the URL’s path, as in step 4, and answer requests to any other path with an error; treat each event as a notification, and confirm anything important through the API before acting on it.
- 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.

