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
}
}| Field | Type | Required | Description |
|---|---|---|---|
applicationId | uuid | yes | The application this template belongs to. Templates without an applicationId cannot be sent via the Template Send API. |
name | string | yes | Internal display name |
subject | string | yes | Email subject line. Supports {{variable}} placeholders. |
body_html | string | yes | HTML email body. Supports {{variable}} placeholders. Sanitized server-side — script tags, event handlers, and dangerous markup are stripped. |
body_text | string | no | Plain-text fallback body. Supports {{variable}} placeholders. |
variables | string[] | no | Declared variable names for documentation and validation. |
senderId | uuid | no | The 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_active | boolean | no | Whether the template is active (default: true). |
Important: The
applicationIdfield 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 withapplication_id: nulland 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"
}
}
}| Field | Type | Required | Description |
|---|---|---|---|
organization_id | uuid | yes | Your organization ID |
name | string | yes | Internal display name |
from_email | string | yes | From address. Must be verified with a configured domain. |
from_name | string | no | Display name shown in email clients |
reply_to_email | string | no | Reply-To address |
provider | string | yes | Email provider: ses (AWS SES) or others |
provider_config | object | no | Provider-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
}
}| Field | Type | Required | Description |
|---|---|---|---|
organization_id | uuid | yes | Your organization ID |
domain | string | yes | Domain name (e.g., acme.com) |
mode | string | yes | Sending mode: ses (AWS SES) |
ses_region | string | no | AWS region for SES (e.g., us-east-1) |
applies_to_all_apps | boolean | no | When true, the domain is available to all apps in the org |
notes | string | no | Internal 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:
| Status | Description |
|---|---|
sent | Accepted by the provider |
delivered | Confirmed delivered to recipient’s mail server |
opened | Recipient opened the email (pixel tracking) |
clicked | Recipient clicked a link |
bounced | Delivery failed permanently |
soft_bounced | Delivery failed temporarily |
complained | Recipient marked as spam |
unsubscribed | Recipient 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/sendRequest body:
{
"to": [{ "email": "user@example.com", "name": "Alice" }],
"variables": { "reset_code": "abc123" }
}| Field | Type | Required | Description |
|---|---|---|---|
to | object[] | yes | Recipient list. Each entry requires email and optionally name. |
cc | object[] | no | CC recipients. Same shape as to. |
bcc | object[] | no | BCC recipients. Same shape as to. |
variables | object | no | Variable values to substitute into the template. |
metadata | object | no | Arbitrary key/value pairs stored with the delivery event. |
Success response (200):
{ "code": 200, "message": "OK", "data": { "eventId": "evt_..." } }Error responses:
| Status | Meaning |
|---|---|
| 400 | Missing sender or recipients |
| 402 | Insufficient credits to send email |
| 403 | Template does not belong to the specified app |
| 404 | Template not found |
| 429 | Rate limit exceeded (see below) |
| 500 | Email delivery unavailable |
Rate limits
The send endpoint enforces a per-recipient limit to prevent spamming any individual address:
| Limit | Window | Default |
|---|---|---|
| Per recipient address, per application | 1 minute | 3 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"]