What it is
The email builder sends campaigns to an audience and automatic emails on Partner events. The transactional API is the third way: your backend decides who gets which email and when, and passes the data the email needs, the way a SendGrid dynamic template works. One request sends one template to one recipient. Opens, clicks and unsubscribes land in the email’s history like every other send, flagged API.
400 with a code that says which. Set the email’s Send to to Transactional (API): it then has no audience, the manual send is hidden and the list shows it as an API email. An audience email can still be sent through the API; the mode only keeps the two kinds apart in the app.Quickstart
Find the Template ID
Open the email under Emails. The side rail shows Template ID with a Copy button. The same rail tells you when the email cannot be sent through the API yet (no app, still a draft).
Create an API key
In Organization → API access create a key with the
send_emailsscope. Theav_…secret is shown once at creation; keep it server-side, never in frontend code.Send with curl
curl -X POST https://appvitals.io/api/emails/send \ -H "Authorization: Bearer av_..." \ -H "Content-Type: application/json" \ -d '{ "templateId": "4f1c2a7e9b3d4c5e8f6a7b8c", "to": { "email": "[email protected]", "name": "Jane Doe", "shopDomain": "ember-oak.myshopify.com" }, "variables": { "plan": "Pro", "order": { "id": 1024, "total": "€49.00", "items": [{ "sku": "A-1", "qty": 2 }] } } }'A
200response is{"ok": true, "sendId": "…", "status": "sent"}. ThesendIdis the row in the email’s history.Or use the official client
npm install @appvitals/clientimport { AppVitalsClient } from "@appvitals/client"; const appvitals = new AppVitalsClient({ apiKey: process.env.APPVITALS_API_KEY!, // av_... }); await appvitals.sendEmail({ templateId: "4f1c2a7e9b3d4c5e8f6a7b8c", to: { email: "[email protected]", name: "Jane Doe", shopDomain: "ember-oak.myshopify.com" }, variables: { plan: "Pro", order: { id: 1024, total: "€49.00", items: [{ sku: "A-1", qty: 2 }] }, }, });The client is server-side Node (18+). By default a failed call is logged and resolves to
nullinstead of rejecting; opt into rejections withonError: "throw"when the send matters (a password reset, an order confirmation).
Variables and Liquid
Every template is a Liquid template: the subject, the preview text and the body. The classic merge tags keep working and anything you pass in variables is available by name, nested objects and arrays included.
Subject: Your order {{ order.id }} is on its way, {{ first_name | default: "there" }}
{% if plan == "Pro" %}
Thanks for being a Pro customer.
{% else %}
Upgrade to Pro for free shipping.
{% endif %}
{% for item in order.items %}
{{ item.sku }} × {{ item.qty }}
{% endfor %}
Total: {{ order.total }}- Defaults:
{{ first_name | default: "there" }}renders the fallback when the value is missing or empty - Filters:
upcase,downcase,capitalize,truncate: 40,date: "%b %d, %Y",plus,money-style formatting withroundand the rest of the standard Liquid filter set - Conditions and loops:
{% if %},{% elsif %},{% unless %},{% for %},{% case %} - Missing values render as empty; the preview in the app shows exactly what a recipient gets, so add a
defaultwhere a blank would look wrong - Values are inserted as-is (the email is HTML). Add
| escapeto a value that comes from user input
| Tag | Value |
|---|---|
first_name | First word of the contact's name, else of to.name |
shop_name | Contact name, else the shop domain without the myshopify suffix |
shop_domain | The contact's myshopify domain (when the address is a stored contact) |
email | The recipient address |
country | The contact's country code |
app_name | Display name of the app the template is bound to |
sender_name, sender_email, sender_address | The template's sender |
unsubscribe_url | The signed unsubscribe link; reserved, cannot be overridden |
Merge tags come from the stored contact when the recipient address is one (push contacts with identify or the contacts endpoint); for an unknown address to.name fills first_name and shop_name. Your variables win on a name clash.
Request reference
| Field | Meaning |
|---|---|
templateId | Required. The Template ID from the email's detail page. |
to | Required. An email address, or {email, name, shopDomain}. The name is used for first_name and shop_name when the address is not a stored contact. |
to.shopDomain | Optional but recommended. The store's myshopify domain (myshopifyDomain works too). Pairs the send to that store: the email history shows it in the Store column, the shop's page lists the email under Emails, and it fills shop_domain and shop_name. One address can own several stores, so a send without it is not paired to any. |
variables | Optional JSON object: nested objects and arrays, numbers, booleans, null. Up to 5 levels deep, 200 values, 5000 characters per string, 64 KB in total. |
| Status | Meaning |
|---|---|
200 | Delivered to the provider: {ok, sendId, status: "sent"}. |
400 | invalid_request (malformed body), template_without_app, template_draft, template_empty or sender_required. |
401 / 403 | Missing or invalid key, or the key lacks the send_emails scope. |
404 | template_not_found, or the email builder is not enabled for the organization. |
409 | recipient_unsubscribed: the address opted out of the template's unsubscribe group; nothing was sent. |
422 | render_failed: the template did not render for these variables; the error names the part and the Liquid problem. Recorded as a failed send. |
429 | rate_limited: more than 300 sends in a minute for the organization; retry after the Retry-After seconds. |
502 | delivery_failed: the email provider refused the message; recorded as a failed send with its sendId. |
Every error body carries error (human-readable) and code (stable). The full schema is in the API reference.
Good to know
- Pass to.shopDomain whenever you know which store the email is about. Without it the send is recorded against the address only; the stored contact's store is never assumed, because one address can own several stores.
- Unsubscribes are honoured: an address that opted out of the template's group is refused with 409, so a transactional email you must always deliver belongs in its own unsubscribe group.
- Each send is one request; there is no batch endpoint. Loop on your side and keep under 300 requests a minute per organization.
- Test the rendering before going live: the Preview in the email detail and the test send use the same Liquid engine. A template with a syntax error cannot be saved, so a 422 at send time means the variables you passed hit a runtime problem.
- The same template can still be sent manually to an audience or by a trigger; the API does not change how it behaves elsewhere.