S
supero.docs
Documentation/Building Your App/Email & Notifications

Email & Notifications

Send transactional email through SendGrid or your own SMTP server, push in-app notifications through their own queued → sent → seen lifecycle, and wire both into workflows so a data change can email, alert, and acknowledge on its own.

Overview

Supero gives you two managed channels for reaching people, plus a third for reaching your own team's tools. Transactional email (service_id email for SendGrid, or email_smtp for any SMTP server) sends a real message to an inbox. In-app notifications (service_id notification) are queued → sent → seen records that live inside your app's own UI and never leave it. Chat/SMS channels (slack, sms, whatsapp) are separate managed-services-catalog entries with their own field contracts, covered on the Managed Integrations & Provider Catalog page — this page focuses on email and in-app notifications, and shows where the three meet in a workflow.
Every one of these goes through the same wire primitive once its service is imported and configured for your project:
bash
curl -X POST https://api.supero.dev/api/v1/services/execute \
  -H "X-API-Key: $SUPERO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "service_id": "email",
    "operation": "send_email",
    "input": {
      "to_email": "[email protected]",
      "subject": "Your order shipped",
      "body_html": "<p>Order #4471 is on its way.</p>"
    },
    "domain": "acme-corp",
    "project_uuid": "8f2a1c40-..."
  }'

ℹ️ Import before you execute

A service has to be imported and configured for your project before /services/execute will call it — see Importing & Configuring the Email Service below. GET https://api.supero.dev/api/v1/services/catalog lists everything available and what's already imported.

The Delivery Services

Pick the channel by where the message needs to land, not by habit. All six service_ids below share the same import → configure → execute lifecycle; what differs is the destination and the field contract.
service_idLands whereRecipient identityTypical use
emailAn external inbox, via SendGrid.to_email addressReceipts, invoices, welcome mail, password resets, marketing-style transactional copy — with open/click tracking and bounce handling.
email_smtpAn external inbox, via any SMTP server you control (Gmail, Outlook/365, AWS SES, a self-hosted relay).to_email addressSame as email, when you want to send from your own domain/mail server instead of SendGrid.
notificationInside your app's own UI — a bell icon, an inbox panel.A recipient reference on your own extending schemaReal-time in-app alerts that shouldn't leave the app: “your ticket was updated,” “new comment on your order.”
smsA phone, as a text message, via Twilio.E.164 phone numberTime-sensitive, short-form alerts — shipment updates, appointment reminders.
whatsappWhatsApp, via Twilio.Phone numberSame use case as sms, with richer templates and media.
slackA Slack workspace channel or DM.Slack channel/user idInternal team notifications — not customer-facing.

💡 Full provider catalog

Config fields, sandbox credentials, and the rest of the ~38-provider managed catalog (including sms, whatsapp, and slack) are documented on the Managed Integrations & Provider Catalog page. This page goes deep on email and notification specifically.

Importing & Configuring the Email Service

email (SendGrid) and email_smtp (any SMTP host) ship nearly identical schemas and the same send_email operation — they differ in provider and config_schema. Import whichever one matches how you want to send, then configure it with PUT before your first send.

email (SendGrid)

bash
curl -X POST https://api.supero.dev/api/v1/services/import \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "service_id": "email",
    "domain": "acme-corp",
    "project_uuid": "8f2a1c40-...",
    "config_data": {
      "sendgrid_api_key": "SG.xxxxxxxxxxxx",
      "default_from_email": "[email protected]",
      "default_from_name": "Acme Corp"
    }
  }'
Config fieldRequiredDefaultNotes
sendgrid_api_keyyes—Stored encrypted, returned masked on read.
default_from_emailyes—Used whenever a send omits from_email.
default_from_nameyes—Used whenever a send omits from_name.
default_reply_tono—Used whenever a send omits reply_to.
base_urlyes—Your own API host, used to build the SendGrid event-webhook callback URL — https://api.supero.dev on Supero Cloud, your own API host if self-hosted.
sendgrid_webhook_signing_keyno—Verifies inbound SendGrid Event Webhook calls; set it to enable delivery/engagement/bounce tracking (see Deliverability below).
track_opensnotrueEnables SendGrid open tracking.
track_clicksnotrueEnables SendGrid click tracking.
sandbox_modenofalseValidates the request and returns success without actually delivering — good for staging.
daily_send_limitno10000A soft cap you set to match your SendGrid plan.
bounce_thresholdno3Soft bounces to the same address before it is auto-suppressed.

email_smtp instead

bash
curl -X POST https://api.supero.dev/api/v1/services/import \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "service_id": "email_smtp",
    "domain": "acme-corp",
    "project_uuid": "8f2a1c40-...",
    "config_data": {
      "smtp_host": "smtp.gmail.com",
      "smtp_username": "[email protected]",
      "smtp_password": "an-app-specific-password",
      "default_from_email": "[email protected]"
    }
  }'
Config fieldRequiredDefaultNotes
smtp_hostyes—e.g. smtp.gmail.com, smtp.office365.com, email-smtp.{region}.amazonaws.com.
smtp_portno587587 for STARTTLS, 465 for implicit SSL, 25 unencrypted.
smtp_usernameyes—Usually the sending mailbox address.
smtp_passwordyes—Stored encrypted — an app-specific password for Gmail/365, not your account password.
smtp_use_tlsnotrueSTARTTLS, recommended for port 587.
smtp_use_sslnofalseImplicit SSL, for port 465.
default_from_emailyes—Used whenever a send omits from_email.
default_from_nameno—Used whenever a send omits from_name.
default_reply_tono—Used whenever a send omits reply_to.
base_urlyes—Your own API host (SMTP has no webhook callback, but the field is still required).
Update either service's credentials at any time with PUT /api/v1/services/{service_id}/config — config_data is merged into what's already stored, so send only the fields you're changing.

💡 Test credentials without spending real send quota

A free SendGrid API key plus sandbox_mode: true validates every request and returns success without delivering anything. For email_smtp, a throwaway SMTP test-inbox provider mints disposable credentials in seconds. See the Managed Integrations & Provider Catalog page's sandbox-credentials table for both.

send_email Field Reference

Both email and email_smtp expose the same operation_id, send_email, with an almost identical field contract — write your call once and it works against either provider.
FieldRequiredNotes
to_emailyesSingle recipient address.
to_namenoRecipient display name.
subjectyes—
body_htmlat least one of body_html/body_textHTML body.
body_textat least one of body_html/body_textPlain-text body.
ccnoComma-separated addresses.
bccnoComma-separated addresses.
from_emailnoPer-send override of the configured default_from_email.
from_namenoPer-send override of the configured default_from_name.
reply_tonoPer-send override of the configured default_reply_to.
prioritynohigh, normal, or low — mapped to an X-Priority email header.
email (SendGrid) accepts three additional fields that email_smtp does not:
FieldNotes
tagsComma-separated; becomes up to 10 SendGrid categories.
scheduled_atISO-8601 timestamp — SendGrid holds the send until then instead of sending immediately.
message_typetransactional (default) or marketing — stamped onto the tracking record described below.
json
{
  "service_id": "email",
  "operation": "send_email",
  "input": {
    "to_email": "[email protected]",
    "to_name": "Jamie Rivera",
    "subject": "Your order shipped",
    "body_html": "<p>Order #4471 is on its way — tracking attached.</p>",
    "reply_to": "[email protected]",
    "priority": "high"
  },
  "domain": "acme-corp",
  "project_uuid": "8f2a1c40-..."
}
json
{
  "success": true,
  "service_id": "email",
  "operation": "send_email",
  "output": {
    "record_uuid": "9c1d...",
    "provider_message_id": "abc123.filterdrecv-xyz",
    "status": "sent"
  },
  "side_effects": [],
  "record_uuid": "9c1d...",
  "error": null
}

ℹ️ Where sent_at goes

record_uuid, provider_message_id, and status are what the execute call itself returns. Behind the scenes, every send_email call also creates a tracking record — email:message or email_smtp:message — and a successful send stamps status: sent and sent_at onto that record. Query it afterward the same way you'd query any other CRUD type (see Managing Data) if you need the timestamp, or the fuller delivery history covered under Deliverability below.

From Python and JavaScript

python
from supero.service_lib import ServiceLib

sl = ServiceLib(api_key='ak_...', domain='acme-corp', project_uuid='8f2a1c40-...')

result = sl.email.send(
    '[email protected]',
    'Your order shipped',
    body_html='<p>Order #4471 is on its way.</p>',
)

# raw escape hatch — works for email_smtp too
result = sl.exec('email_smtp', 'send_email', {'to_email': '[email protected]', 'subject': '...', 'body_text': '...'})
javascript
await client.transactional.execute('email', 'send_email', {
  to_email: '[email protected]',
  subject: 'Your order shipped',
  body_html: '<p>Order #4471 is on its way.</p>',
});

Three Ways to Send an Email

1. Direct call

Call /services/execute yourself — from a backend job, a script, or a button handler — exactly as shown above. This is the right choice when the send needs to happen synchronously as part of a request you're already handling.

2. Inside a workflow step

A service_call step targeting service: "email" lets a workflow send mail as one step in a larger chain — typically fired automatically by an event binding rather than called directly:
json
{
  "id": "email_notify",
  "type": "service_call",
  "service": "email",
  "operation": "send_email",
  "on_error": "continue",
  "input_map": {
    "to_email": "{{input.email}}",
    "subject": "Welcome to Acme Corp",
    "body_html": "<p>Hi {{input.name}}, your account is ready.</p>"
  }
}
See the Workflows & Automation page for the full step syntax, event bindings, and how to check a run afterward.

3. Platform-managed verification & password-reset mail

Separately from anything you configure, the platform sends its own system email for account flows — a password-reset one-time code through POST /api/v1/auth/forgot-password, verified via POST /api/v1/auth/verify-reset-otp. This channel needs no service import and no credentials from you; it's built into login/signup itself. See the Users & Roles page for the exact request and response shapes.

ℹ️ Not the same thing as auth_otp

The auth_otp catalog integration (send_otp / verify_otp) is a separate, opt-in service for step-up verification inside your own app's flows — confirming a risky action, verifying a phone number — and it does require its own provider credentials (otp_provider_api_key) once imported. The platform's built-in signup/password-reset codes are a different, always-on system channel that needs no configuration at all.

In-App Notifications

notification is an internal service — its operations mutate your own extending-schema record directly instead of calling out to a provider, the same pattern as cart or task. You extend notification:base_notification with a reference to whoever should see it, plus any source-record reference you want to carry along.
json
{
  "name": "order_notification",
  "namespace": "acmestore",
  "parent_type": "tenant",
  "extends": "notification:base_notification",
  "references": [
    { "name": "recipient", "type": "customer", "cardinality": "one" },
    { "name": "order", "type": "order", "cardinality": "one" }
  ]
}
base_notification fieldNotes
statusRequired, defaults to queued. Allowed values: queued, sent, seen, dismissed, archived, failed.
titleRequired.
bodyOptional longer text.
channelFree-form — how you tag which UI surface should render it, e.g. "bell", "toast".
priorityFree-form, your own convention.
action_urlWhere clicking the notification should take the user.
iconIcon identifier for your UI.
sent_at / seen_at / dismissed_at / archived_atStamped by the corresponding operation below.
expires_atOptional — your UI decides what to do with it.
metadataFree-form JSON payload.
OperationTransitionNotes
send_notificationqueued → sentCall after your own in-app dispatch (websocket push, poll response, etc.) succeeds. Stamps sent_at.
mark_seensent → seenIdempotent — calling it again once already seen/dismissed/archived just returns the current state with reason: "already_seen", no error.
dismiss_notificationsent or seen → dismissedUser closed a toast or swiped it away.
archive_notificationany non-terminal status → archivedRemoves it from the active feed.
mark_all_seenbulk: every sent record matching a recipient field → seenTakes recipient_field (the reference field name on your extending schema, e.g. "recipient_uuid") and recipient_uuid.
A notification record starts life as an ordinary CRUD create — status defaults to queued, no operation call needed yet:
bash
curl -X POST https://api.supero.dev/api/v1/crud/acme-corp/acmestore:order_notification \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "order-4471-shipped",
    "title": "Your order shipped",
    "body": "Order #4471 is on its way.",
    "recipient": "customer-uuid-here",
    "order": "order-uuid-here"
  }'
Then move it through its lifecycle with /services/execute — every operation needs notification_uuid and extending_schema:
json
{
  "service_id": "notification",
  "operation": "send_notification",
  "input": {
    "notification_uuid": "<uuid from the create above>",
    "extending_schema": "acmestore:order_notification"
  },
  "domain": "acme-corp",
  "project_uuid": "8f2a1c40-..."
}

⚠️ Always pass extending_schema

notification's operations have no crud_object_type — they read and write whatever record extending_schema points at, so an operation call without it (or with the wrong one) fails rather than guessing.

Contact Forms That Trigger an Email Workflow

A public contact form is just a schema record plus an event binding — no special “form” concept exists on the platform. Define a plain schema for the submission, render the form on a public page, and let an @create event fire a workflow that emails your team and acknowledges the sender.

1. Define the schema

json
{
  "name": "contact_message",
  "namespace": "acmestore",
  "parent_type": "tenant",
  "attributes": [
    { "name": "sender_name", "type": "string", "mandatory": true },
    { "name": "sender_email", "type": "string", "mandatory": true },
    { "name": "message", "type": "string", "mandatory": true },
    { "name": "status", "type": "string", "values": ["new", "acknowledged", "resolved"], "default-value": "new" }
  ]
}

2. Put the form on a public page

Render it on a live_app_public page (see Dynamic UI — the public, unauthenticated storefront/landing surface). Submitting calls client.createObject("contact_message", {...}) exactly like any other write in your app. If the role that reaches this page is already permitted to create records, this works as-is. If you've locked entity access down for that role (default_access: none), grant create explicitly:
json
// access_rule, parent = an access_policy for that role
{
  "entity": "contact_message",
  "can_create": true,
  "can_read": false,
  "can_update": false,
  "can_delete": false
}
See Access Policies (Entity, Record & Field) for the full access_policy / access_rule model.

3. Bind an @create event to a workflow

python
{
    "workflow_id": "contact_form_notify",
    "display_name": "Contact Form: Notify + Acknowledge",
    "status": "Active", "enabled": True, "on_error": "continue",
    "input_schema": {
        "sender_name": {"type": "string", "required": True},
        "sender_email": {"type": "string", "required": True},
        "message": {"type": "string", "required": True},
    },
    "steps": [
        {"id": "notify_team", "type": "service_call", "service": "email", "operation": "send_email",
         "on_error": "continue",
         "input_map": {
             "to_email": "[email protected]",
             "subject": "New contact form submission",
             "body_html": "<p>{{input.sender_name}} ({{input.sender_email}}) wrote:</p><p>{{input.message}}</p>"}},
        {"id": "acknowledge_sender", "type": "service_call", "service": "email", "operation": "send_email",
         "on_error": "continue",
         "input_map": {
             "to_email": "{{input.sender_email}}",
             "subject": "We got your message",
             "body_html": "<p>Thanks {{input.sender_name}} — we will get back to you soon.</p>"}},
    ],
}
json
{
  "event": "@create:acmestore:contact_message",
  "workflow_id": "contact_form_notify",
  "input_map": {
    "sender_name": "sender_name",
    "sender_email": "sender_email",
    "message": "message"
  }
}

💡 input_map reads the new record itself

For an @create event, the payload the input_map paths resolve against is the record that was just created — so a bare field name like sender_email is that field on the new contact_message row. Full syntax, nesting, and reference hydration are on the Workflows & Automation page.

Deliverability Basics You Own

Supero routes the send; the provider decides whether it lands in an inbox or a spam folder. A few things are on you to get right, mostly relevant to email (SendGrid) rather than email_smtp.
  • •Verified sender/domain — default_from_email must be an address or domain you've verified directly with SendGrid (or, for email_smtp, one your SMTP host is actually authorized to send as). This verification happens with the provider, not through a Supero endpoint.
  • •sandbox_mode — set it true on the email service while you're building; every send validates and returns success without actually delivering. Turn it off before launch.
  • •daily_send_limit and bounce_threshold — soft caps you configure to match your SendGrid plan's real limits; bounce_threshold controls how many soft bounces to the same address trigger an auto-suppress.
  • •Suppression list — bounces, unsubscribes, and spam reports land in the email:suppression schema; check it before a send if you're doing your own bulk mailing logic.
bash
curl -X POST https://api.supero.dev/api/v1/services/execute \
  -H "X-API-Key: $SUPERO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "service_id": "email",
    "operation": "check_suppression",
    "input": {"email_address": "[email protected]"},
    "domain": "acme-corp",
    "project_uuid": "8f2a1c40-..."
  }'
json
{
  "success": true,
  "service_id": "email",
  "operation": "check_suppression",
  "output": {
    "is_suppressed": false,
    "reason": "",
    "suppressed_at": "",
    "can_retry": true
  }
}
email:suppression fieldNotes
email_addressRequired.
reasonRequired — bounce, unsubscribe, spam_report, etc.
bounce_type / bounce_code / bounce_descriptionPopulated for bounce-sourced suppressions.
source_message_uuid / source_campaign_uuidTraces back to what triggered the suppression.
suppressed_atRequired.
expires_atOptional — some suppression reasons are temporary.
Set sendgrid_webhook_signing_key on the email service's config to let SendGrid's Event Webhook keep delivered_at, opened_at, clicked_at, and bounced_at current on your email:message records as events arrive, and get_delivery_stats gives you an aggregate rollup (total_sent, delivered, opened, clicked, bounced, failed, plus rates) via the same /services/execute call.

⚠️ email_smtp has none of this

A raw SMTP connection has no return channel for opens, clicks, or bounce notifications — you only get sent_at and error_message on the email_smtp:message record. If delivery/engagement tracking and an auto-managed suppression list matter to you, use email (SendGrid) instead.

Next steps