# 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

- **Credentials.** You need your API ID and API Key, and the API must be enabled for your account; see [Authentication](https://envoke-developers.pages.dev/guides/authentication/). The examples read the credentials from the `API_ID` and `API_KEY` shell variables set up in [Make a Request](https://envoke-developers.pages.dev/guides/authentication/#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](https://envoke-developers.pages.dev/guides/which-api/#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](https://envoke-developers.pages.dev/guides/authentication/#api-limits).

---

## 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:

```bash
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"
```

```json
{
  "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](https://envoke-developers.pages.dev/v2/customfields/insert/).

Create a tag for members who volunteer:

```bash
curl -X POST -gs \
  -u "$API_ID:$API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "Volunteer"}' \
  "https://e1.envoke.com/v2/tags"
```

```json
{
  "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](https://envoke-developers.pages.dev/v2/tags/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

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:

```bash
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:

```json
{
  "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](https://envoke-developers.pages.dev/v1/contacts/fields/#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](https://envoke-developers.pages.dev/v1/contacts/insert/#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`](https://envoke-developers.pages.dev/v1/contacts/fields/#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](https://envoke-developers.pages.dev/v1/contacts/fields/#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](https://envoke-developers.pages.dev/v1/contacts/fields/#error-responses).

---

## Step 3: Create a Template

Create a template from your own HTML:

```bash
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"
```

```json
{
  "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](https://envoke-developers.pages.dev/v2/templates/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](https://envoke-developers.pages.dev/v1/send/fields/#tracking-and-merge-fields)).

---

## 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:

```bash
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:

```json
{"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](https://envoke-developers.pages.dev/v1/hooks/insert/) and [`filter`](https://envoke-developers.pages.dev/v1/hooks/fields/#filter).

---

## Step 5: Send the Template to One Address

Send the template to the contact with the Send API:

```bash
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"
```

```json
{
  "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](https://envoke-developers.pages.dev/v1/send/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](https://envoke-developers.pages.dev/v1/send/send/#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`](https://envoke-developers.pages.dev/v1/send/fields/#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.

---

## 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:

```json
[
  {
    "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](https://envoke-developers.pages.dev/v1/hooks/payloads/#email_send).

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](https://envoke-developers.pages.dev/v1/hooks/payloads/#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](https://envoke-developers.pages.dev/guides/authentication/#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

- [Which API do I use?](https://envoke-developers.pages.dev/guides/which-api/) 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](https://envoke-developers.pages.dev/v2/tags/contacts/) adds up to 1,000 contacts to a tag in one request.
- [Message Stats](https://envoke-developers.pages.dev/v2/reports/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](https://developers.envoke.com).*
