Transactional emails via API

Send any email you designed in the builder to one recipient from your own backend: a template ID, the recipient and JSON variables the template renders with Liquid, tracked like every other send.

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.

NoteOnly templates bound to an app and marked active can be sent through the API. A draft or an email without an app answers 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

  1. 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).

  2. Create an API key

    In Organization → API access create a key with the send_emails scope. The av_… secret is shown once at creation; keep it server-side, never in frontend code.

  3. 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 200 response is {"ok": true, "sendId": "…", "status": "sent"}. The sendId is the row in the email’s history.

  4. Or use the official client

    npm install @appvitals/client
    import { 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 null instead of rejecting; opt into rejections with onError: "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 with round and 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 default where a blank would look wrong
  • Values are inserted as-is (the email is HTML). Add | escape to a value that comes from user input
Built-in merge tags
TagValue
first_nameFirst word of the contact's name, else of to.name
shop_nameContact name, else the shop domain without the myshopify suffix
shop_domainThe contact's myshopify domain (when the address is a stored contact)
emailThe recipient address
countryThe contact's country code
app_nameDisplay name of the app the template is bound to
sender_name, sender_email, sender_addressThe template's sender
unsubscribe_urlThe 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

Request body
FieldMeaning
templateIdRequired. The Template ID from the email's detail page.
toRequired. 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.shopDomainOptional 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.
variablesOptional JSON object: nested objects and arrays, numbers, booleans, null. Up to 5 levels deep, 200 values, 5000 characters per string, 64 KB in total.
Responses
StatusMeaning
200Delivered to the provider: {ok, sendId, status: "sent"}.
400invalid_request (malformed body), template_without_app, template_draft, template_empty or sender_required.
401 / 403Missing or invalid key, or the key lacks the send_emails scope.
404template_not_found, or the email builder is not enabled for the organization.
409recipient_unsubscribed: the address opted out of the template's unsubscribe group; nothing was sent.
422render_failed: the template did not render for these variables; the error names the part and the Liquid problem. Recorded as a failed send.
429rate_limited: more than 300 sends in a minute for the organization; retry after the Retry-After seconds.
502delivery_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.
Transactional Email API: Send Templates with Variables · AppVitals