Skip to content

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.


  • 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_ID and API_KEY shell 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 uses jq 1.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:

Terminal window
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:

Terminal window
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:

Terminal window
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 Volunteer again 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 interests more loosely than filter[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_fields is keyed by name (see custom_fields). Custom field names are unique in the account, whatever their letter case.

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:

Terminal window
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:

Terminal window
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, not keyfield. ?keyfield=remote_id is ignored without an error, so the request matches by email instead.
  • email is required even when key_field=remote_id is used. If your account checks email addresses for duplicates, an email that belongs to another contact fails with contact with email = ama.osei@example.org already exists. That contact usually has no remote_id yet, for example because the person signed up through a form. Look it up with GET 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 with PATCH https://e1.envoke.com/v1/contacts?key_field=email and 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_status leaves the contact’s current status unchanged, and a consent downgrade is recorded in the contact’s history without becoming current unless consent_force_downgrade is true. 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.
  • interests adds unless told otherwise. The object form {"9101": "Set"} adds the tag with ID 9101; {"9101": "Unset"} removes it. The list form, ["Volunteer"], also works, by name, and only adds. In a read, interests lists names.
  • Custom field keys. On write, custom_fields keys match the field name ignoring case, with a space and an underscore treated as the same, so "plot number" writes to Plot_Number. In read filters the name is case-sensitive: filter[custom_fields.Plot_Number]=B-14. See custom_fields.
  • Warnings are prose. An unrecognised country or language value does not fail the request; a warning is appended to result_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 with result_data.fields keyed by database column, and a plain envelope for an empty or unreadable body. See Contact API errors.

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>&nbsp;</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:

Terminal window
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_VERSION meta tag makes the template open in the drag-and-drop editor, and the block-outer, block-inner, and content-edit classes 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’s Plot_Number value from step 2, read when the email is sent. The name after customfield- must match the custom field’s name exactly, including its capitalisation: {@customfield-plot_number} would be replaced with nothing (see Custom Fields).

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:

Terminal window
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.


Send the template to the contact with the Send API:

Terminal window
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. success is true when at least one recipient is sent the message. A recipient who is not sent it is listed with the reason, such as excluded_unsubscribed_contacts, and counted in summary (see Response). When no recipient can be sent it, the response is HTTP 400 with Message not sent, there are no valid recipients and 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 credits or Message 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 subject is 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 replace values 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 429 response was refused before anything happened and can be sent again after a wait. But Internal error; please contact Envoke support, Invalid request, a 5xx status, or no response at all can leave a message that already exists. 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-28 returns [] 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.


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_send events are batched, so one POST can carry many events, for many contacts and for messages your integration did not send. Route each one by contact_remote_id or message_id, and ignore the rest.
  • Return a status below 400 within 60 seconds. Anything else, or no answer, is retried once after 10 minutes, and then the events are dropped. A 410 response 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 same activity_type, message_id, contact_id, and timestamp as one, and order events by timestamp.
  • 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.


  • 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.