Skip to Content
Email

Email

Picklayer’s email system lets you send transactional and automated emails from your application. You define templates with variable substitution, configure senders (from-address + provider), verify domains for deliverability, and track every delivery via events.

All email management operations go through the Management API GraphQL endpoint (https://api.picklayer.ai/v1/graphql) using a builder organization token.


Templates

Email templates store reusable HTML/text content with named variables that are substituted at send time.

List templates

query ListEmailTemplates($orgId: uuid!, $appId: uuid!) { email_templates( where: { organization_id: { _eq: $orgId } application_id: { _eq: $appId } is_active: { _eq: true } } order_by: { created_at: desc } ) { id name subject body_html body_text variables sender_id is_active created_at } }

Get a template

query GetEmailTemplate($templateId: uuid!) { email_templates_by_pk(id: $templateId) { id name subject body_html body_text variables sender_id is_active created_at updated_at } }

Create a template

Template creation goes through the BuilderEmailTemplateCreate Hasura action, which sanitizes HTML server-side before persistence.

mutation BuilderEmailTemplateCreate($input: BuilderEmailTemplateCreateInput!) { BuilderEmailTemplateCreate(input: $input) { code message type data { resource } } }

Variables:

{ "input": { "applicationId": "5e71e89d-b978-4153-8faf-539eeefd5779", "name": "Welcome Email", "subject": "Welcome to {{app_name}}, {{first_name}}!", "body_html": "<h1>Hi {{first_name}},</h1><p>Welcome to {{app_name}}.</p>", "body_text": "Hi {{first_name}}, Welcome to {{app_name}}.", "variables": ["first_name", "app_name"], "senderId": "sender-uuid", "is_active": true } }
FieldTypeRequiredDescription
applicationIduuidyesThe application this template belongs to. Templates without an applicationId cannot be sent via the Template Send API.
namestringyesInternal display name
subjectstringyesEmail subject line. Supports {{variable}} placeholders.
body_htmlstringyesHTML email body. Supports {{variable}} placeholders. Sanitized server-side — script tags, event handlers, and dangerous markup are stripped.
body_textstringnoPlain-text fallback body. Supports {{variable}} placeholders.
variablesstring[]noDeclared variable names for documentation and validation.
senderIduuidnoThe sender identity to use for this template. If omitted, Picklayer falls back to the organization’s verified sender. Set this to ensure the correct from-address is always used.
is_activebooleannoWhether the template is active (default: true).

Important: The applicationId field must be passed explicitly in the mutation input. It is not inferred from HTTP headers or JWT claims. If omitted, the template will be created with application_id: null and the Template Send API will return 404 when you try to send it.

Update a template

mutation BuilderEmailTemplateUpdate($input: BuilderEmailTemplateUpdateInput!) { BuilderEmailTemplateUpdate(input: $input) { code message type data { resource } } }

Variables:

{ "input": { "applicationId": "5e71e89d-b978-4153-8faf-539eeefd5779", "templateId": "template-uuid", "name": "Updated Name", "subject": "New subject line" } }

Send only the fields you want to change:

{ "templateId": "template-uuid", "changes": { "subject": "Updated subject line", "body_html": "<p>Updated content</p>" } }

Delete a template

mutation DeleteEmailTemplate($templateId: uuid!) { delete_email_templates_by_pk(id: $templateId) { id } }

Template stats

query TemplateStats($orgId: uuid!) { email_template_stats( where: { organization_id: { _eq: $orgId } } order_by: { total_sent: desc } ) { template_id total_sent total_delivered total_opened total_bounced } }

Senders

A sender defines a from-address, display name, and the underlying email provider to use for delivery.

List senders

query ListSenders($orgId: uuid!) { email_senders( where: { organization_id: { _eq: $orgId } } order_by: { created_at: desc } ) { id name from_email from_name reply_to_email provider created_at } }

Get a sender

query GetSender($senderId: uuid!) { email_senders_by_pk(id: $senderId) { id name from_email from_name reply_to_email provider provider_config created_at updated_at } }

Create a sender

mutation CreateSender($object: email_senders_insert_input!) { insert_email_senders_one(object: $object) { id name from_email created_at } }

Variables:

{ "object": { "organization_id": "org-uuid", "name": "Transactional Sender", "from_email": "hello@acme.com", "from_name": "Acme", "reply_to_email": "support@acme.com", "provider": "ses", "provider_config": { "region": "us-east-1" } } }
FieldTypeRequiredDescription
organization_iduuidyesYour organization ID
namestringyesInternal display name
from_emailstringyesFrom address. Must be verified with a configured domain.
from_namestringnoDisplay name shown in email clients
reply_to_emailstringnoReply-To address
providerstringyesEmail provider: ses (AWS SES) or others
provider_configobjectnoProvider-specific configuration (e.g., { "region": "us-east-1" } for SES)

Update a sender

mutation UpdateSender($senderId: uuid!, $changes: email_senders_set_input!) { update_email_senders_by_pk( pk_columns: { id: $senderId } _set: $changes ) { id name updated_at } }

Delete a sender

mutation DeleteSender($senderId: uuid!) { delete_email_senders_by_pk(id: $senderId) { id } }

Domains

Verified sending domains establish deliverability reputation and allow you to use custom from-addresses.

List domains

query ListDomains($orgId: uuid!) { email_domains( where: { organization_id: { _eq: $orgId } } order_by: { created_at: desc } ) { id domain mode ses_region applies_to_all_apps created_at email_domain_applications { application_id } } }

Create a domain

mutation CreateDomain($object: email_domains_insert_input!) { insert_email_domains_one(object: $object) { id domain mode created_at } }

Variables:

{ "object": { "organization_id": "org-uuid", "domain": "acme.com", "mode": "ses", "ses_region": "us-east-1", "applies_to_all_apps": true } }
FieldTypeRequiredDescription
organization_iduuidyesYour organization ID
domainstringyesDomain name (e.g., acme.com)
modestringyesSending mode: ses (AWS SES)
ses_regionstringnoAWS region for SES (e.g., us-east-1)
applies_to_all_appsbooleannoWhen true, the domain is available to all apps in the org
notesstringnoInternal notes

Update a domain

mutation UpdateDomain($id: uuid!, $changes: email_domains_set_input!) { update_email_domains_by_pk( pk_columns: { id: $id } _set: $changes ) { id domain updated_at } }

Delete a domain

mutation DeleteDomain($id: uuid!) { delete_email_domains_by_pk(id: $id) { id } }

Associate a domain with specific apps

When applies_to_all_apps is false, explicitly link the domain to specific applications:

mutation AddDomainApps($objects: [email_domain_applications_insert_input!]!) { insert_email_domain_applications(objects: $objects) { affected_rows } }
{ "objects": [ { "domain_id": "domain-uuid", "application_id": "app-uuid-1" }, { "domain_id": "domain-uuid", "application_id": "app-uuid-2" } ] }

Remove domain associations

mutation RemoveDomainApps($domainId: uuid!, $appIds: [uuid!]!) { delete_email_domain_applications( where: { domain_id: { _eq: $domainId } application_id: { _in: $appIds } } ) { affected_rows } }

Events

Email events track the delivery lifecycle of each sent message (sent, delivered, opened, bounced, etc.). Events are typically written by the provider webhook handler and queried for reporting.

List events

query ListEmailEvents($orgId: uuid!, $limit: Int!, $offset: Int!) { email_events( where: { organization_id: { _eq: $orgId } } limit: $limit offset: $offset order_by: { created_at: desc } ) { id template_id to_email from_email subject status provider created_at delivered_at opened_at } }

Record an event (server-side)

mutation CreateEmailEvent($object: email_events_insert_input!) { insert_email_events_one(object: $object) { id status created_at } }
{ "object": { "organization_id": "org-uuid", "template_id": "template-uuid", "to_email": "user@example.com", "from_email": "hello@acme.com", "subject": "Welcome!", "status": "sent", "provider": "ses" } }

Update event status

Update an event as delivery status changes (typically triggered by provider webhooks):

mutation UpdateEventStatus($eventId: uuid!, $changes: email_events_set_input!) { update_email_events_by_pk( pk_columns: { id: $eventId } _set: $changes ) { id status updated_at } }
{ "eventId": "event-uuid", "changes": { "status": "delivered", "delivered_at": "2026-01-15T10:30:00Z" } }

Event status values:

StatusDescription
sentAccepted by the provider
deliveredConfirmed delivered to recipient’s mail server
openedRecipient opened the email (pixel tracking)
clickedRecipient clicked a link
bouncedDelivery failed permanently
soft_bouncedDelivery failed temporarily
complainedRecipient marked as spam
unsubscribedRecipient unsubscribed

Sending a template via API

Use the REST endpoint to trigger a template send from your backend or from a trusted client:

POST /api/org/:orgHandler/app/:appHandler/v1/email/templates/:templateId/send

Request body:

{ "to": [{ "email": "user@example.com", "name": "Alice" }], "variables": { "reset_code": "abc123" } }
FieldTypeRequiredDescription
toobject[]yesRecipient list. Each entry requires email and optionally name.
ccobject[]noCC recipients. Same shape as to.
bccobject[]noBCC recipients. Same shape as to.
variablesobjectnoVariable values to substitute into the template.
metadataobjectnoArbitrary key/value pairs stored with the delivery event.

Success response (200):

{ "code": 200, "message": "OK", "data": { "eventId": "evt_..." } }

Error responses:

StatusMeaning
400Missing sender or recipients
402Insufficient credits to send email
403Template does not belong to the specified app
404Template not found
429Rate limit exceeded (see below)
500Email delivery unavailable

Rate limits

The send endpoint enforces a per-recipient limit to prevent spamming any individual address:

LimitWindowDefault
Per recipient address, per application1 minute3 emails

When a limit is exceeded the endpoint returns HTTP 429 with error.type = "rate_limit_error". Retry after the current minute window expires.

The limit can be adjusted via the EMAIL_RATE_LIMIT_PER_RECIPIENT_PER_MIN environment variable on the API service.


Python Example

import requests HASURA_URL = "https://api.picklayer.ai/v1/graphql" def get_headers(token): return { "Authorization": f"Bearer {token}", "Content-Type": "application/json", } def list_templates(token, org_id, app_id): resp = requests.post(HASURA_URL, json={ "query": """ query($orgId: uuid!, $appId: uuid!) { email_templates(where: { organization_id: {_eq: $orgId}, application_id: {_eq: $appId}, is_active: {_eq: true} }, order_by: {created_at: desc}) { id name subject variables } } """, "variables": {"orgId": org_id, "appId": app_id}, }, headers=get_headers(token)) return resp.json()["data"]["email_templates"] def create_template(token, org_id, app_id, name, subject, body_html, variables=None): resp = requests.post(HASURA_URL, json={ "query": """ mutation($object: email_templates_insert_input!) { insert_email_templates_one(object: $object) { id name subject created_at } } """, "variables": { "object": { "organization_id": org_id, "application_id": app_id, "name": name, "subject": subject, "body_html": body_html, "variables": variables or [], } }, }, headers=get_headers(token)) return resp.json()["data"]["insert_email_templates_one"]
Last updated on