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.

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:

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 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. The Contact API refers to the custom field and the tag by name, not by these IDs.


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": ["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, 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.
  • 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. See Upsert: update when the contact exists.
  • interests as a list only adds. To remove the tag, send the object form, {"Volunteer": "Unset"}.
  • 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.

Create a template from your own HTML:

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


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:

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


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": [{"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. 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. 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 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.
  • replace values are inserted as written, without escaping, so a value can contain HTML. An invalid replace excludes that recipient as excluded_invalid_replace_data instead of failing the request (see replace).
  • 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-28 returns [] when no message has that name. A message with that name means the earlier request created it; read its status before 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.


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_send events are batched, so one POST can carry several events (see Delivery Format).
  • Return a 2xx status. On any other status, Envoke retries once after 10 minutes. A 410 response 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.


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