Skip to content
littleworksdocs

Explore the documentation

Meet LittleworksA little backend for what you’re building on Shopify.Littleworks or your own appWhat it takes to give a Shopify feature a backend: with Littleworks, or with a custom app you build and run yourself.Connect to LittleworksChoose the CLI, a plugin, or a remote MCP connection.CLIConnect from your terminal with Shopify approval and no manual tokens.Claude pluginThe Littleworks MCP connection and agent guidance in one package.OpenAI pluginThe Littleworks MCP connection and agent guidance in one package.MCP with OAuthConnect a compatible client directly, without a marketplace plugin.Build with an agentThe operating guide for agents building on Shopify with Littleworks.Write a workA JavaScript handler, a small manifest, and a version you test and then publish.Keep secretsEncrypted credentials, shared across your store’s works.Call external servicesMake public HTTPS API requests from a work using http.fetch().Store dataDocument collections with a small API, scoped automatically to the work that uses them.Call ShopifyCall Shopify’s Admin and Storefront GraphQL APIs with credentials held by Littleworks.Store filesKeep uploads such as artwork and photos in the merchant’s Shopify Files, uploaded by the browser straight to Shopify.Expose an endpointConnect an existing frontend to a work through a small JSON API.Customer access and invitationsRequire a signed-in shopper or a narrow, expiring invitation before a work runs.RecipesUseful things to build for your store, with the backend already taken care of.Product reviewsCollect customer reviews, verify purchases, and publish approved content directly into your Shopify theme.Customer wishlistsGive signed-in customers a persistent list of products they can revisit across devices.Customer quote requestsCollect a customer’s products, quantities, and requirements without turning a request into an order.Back-in-stock alertsCollect confirmed signups on sold-out variants, email them through your own provider when stock returns, and see every signup and send in your admin.Transactional emailSend email from your works through your own provider, never twice for the same thing, and see what was sent and what failed.Build a merchant pageDescribe your records and what merchants do with them; Littleworks renders a native Shopify admin page.Run works on their ownRun a work when something happens in the store, on a schedule, or from a Shopify Flow workflow.MCP tool referenceTools to inspect, deploy, run, and organize the backend for a connected store.Usage and allowancesSee what your works use, how much capacity remains, and when allowances reset.Inspect and troubleshootFind the relevant run, understand the failure, and make the next change deliberately.Limits and securityThe current runtime boundaries, data isolation model, and execution allowances.
Shopify admin
Recipes
Markdown

Transactional email

Send email from your works through your own provider, never twice for the same thing, and see what was sent and what failed.

Many store features end in an email: a quote reply, a back-in-stock alert, a review request. Your agent builds the sending once, as a small module in the feature that sends: it records what should be sent, sends it through your email provider, records the outcome so a retry never emails anyone twice, and shows recent sends and failures in Shopify admin. Your provider delivers the email and keeps its reputation, suppression list and templates; Littleworks has no mail service of its own.

Give this to your agent
Use Littleworks to send email from my store’s works through my email provider (Postmark, Resend or SendGrid; Klaviyo events for marketing flows). Start with [the feature, such as replies to quote requests]. Keep the provider key in Littleworks secrets, use provider-hosted templates, record every send so retries never send twice, honour unsubscribes and bounces, and add an admin page of recent sends and failures with a retry action. Don’t send real email while testing: use the provider’s test mode or my test addresses. Read https://littleworks.app/docs/recipe-email.md and adapt it to my store.

What you are building

  • A send step in each feature that emails: it writes an outbox record and sends it right away, or leaves it for a scheduled dispatcher.
  • Provider templates with a small, explicit model, holding no personal data beyond what the message needs.
  • An admin page for the feature: Needs attention (failed or unknown), Queued, Sent, Skipped and All, with counts, and Retry, Send again, Mark handled, Suppress address and Pause sending actions.
  • Unsubscribe links for subscribed and marketing email, and suppression of addresses that bounce or complain.

Decide what kind of message it is

MessageKindConsentUnsubscribe
Quote reply, a note about an orderTransactional: a reply to something the customer asked forThe customer’s requestNot required; still honour bounces and complaints
Back-in-stock or price-drop alert the customer signed up forSubscribedThe signup, with the address confirmed firstA link in every email; the provider’s one-click unsubscribe for bulk senders
Review request, win-back, newsletterMarketingShopify email marketing consent, or the merchant’s lawful basisRequired; prefer Klaviyo or the provider’s broadcast tools

This isn’t legal advice: the merchant decides how each message is classified where they sell. Gmail asks bulk senders (5,000 or more a day) for one-click unsubscribe and a visible link on marketing and subscribed mail, and a spam rate below 0.3%. Don’t send marketing through Postmark’s transactional stream; use a broadcast stream.

Choose the provider call

ProviderSendAuthBatchRepeatsNotes
PostmarkPOST https://api.postmarkapp.com/email/withTemplate or /email/batchWithTemplates ({Messages: […]})X-Postmark-Server-TokenUp to 500 messages per callNo idempotency key. A batch returns 200 even when some messages fail: check each ErrorCode (0 is accepted) by positionMessageStream defaults to transactional (outbound); TemplateAlias and TemplateModel; Metadata and Tag
ResendPOST https://api.resend.com/emails or /emails/batch (a JSON array)Authorization: Bearer …Up to 100 per batch, no attachmentsIdempotency-Key header, kept 24 hoursTemplates or HTML; tags; GET /emails/{id} shows the latest event
SendGridPOST https://api.sendgrid.com/v3/mail/sendAuthorization: Bearer …Up to 1,000 personalizationsNone202 means accepted. Dynamic templates (template_id, dynamic_template_data); custom_args; asm.group_id for unsubscribe groups
Klaviyo (marketing flows)POST https://a.klaviyo.com/api/events, or bulk event jobsAuthorization: Klaviyo-API-Key … and the revision headerBulk jobs take up to 1,000 eventsunique_id: a repeat for the same profile and metric is ignored202 means accepted, not processed. Klaviyo flows own the template, consent and unsubscribe

Check the provider’s current documentation for limits before relying on them. In practice Littleworks’ request size sets the batch: one request body is at most 64 KiB, so use provider-hosted templates and send only each message’s template model. About 100 Postmark template messages fit in 60 KiB; measure the serialized body and stop adding messages before it. A run can make 50 external requests of up to 5 seconds each within its 10 seconds.

Where the data lives

Keep these collections in the feature that sends (for example quotes). Data is scoped by store and feature, and an atomic db.batch works within one feature, so the outbox lives beside the records it emails about.

CollectionDocumentWhy
outboxID: the SHA-256 of a send key such as quote_reply:{quoteId}:{address} (document IDs allow letters, digits, _ and -). purpose, kind, to, toHash, customerId, subjectId, model, status, reason, attempts, claimedRunId, claimedAt, sentAt, providerMessageId, lastError (a code, never a body), testCreating an existing ID conflicts, so the same send is recorded once. The page reads it. Expires after 90 days to limit documents and personal data
suppressionsID: the SHA-256 of the lowercased address, which isn’t stored. reason (unsubscribed, hard_bounce, complaint, manual), purpose, source, atChecked before every send
settingsOne document: from, enabled, testRecipients, reconciledThroughA sending switch and test allowlist without redeploying

Statuses: queued, then sending (claimed by a run), then sent; failed when the provider refused it (nothing was sent; safe to retry); unknown when a timeout or interrupted run means it may have been sent (never retried automatically); skipped with a reason (suppressed, test, consent); handled once staff dismiss it. Keep addresses out of logs and errors, and store customerId beside the address so a privacy request can find it. Each outbox record is one document until it expires; check the document allowance (Starter has 2,000) before choosing retention.

Before you build

  • Secrets, added by the merchant in Littleworks → Works → Secrets (get_context.secrets.manageUrl), never in chat: the provider key with the least access that sends, such as POSTMARK_SERVER_TOKEN (one server), RESEND_API_KEY, SENDGRID_API_KEY (Mail Send) or KLAVIYO_PRIVATE_KEY (events:write); a …_TEST key for test runs; and EMAIL_LINK_KEY, a random value of at least 32 bytes for unsubscribe links.
  • A verified sending domain or sender at the provider, with SPF, DKIM and DMARC. Littleworks can’t check this.
  • Shopify: no permission is needed to send. Product details for the template come from the included Storefront catalog access. Littleworks can’t read customers’ email addresses or marketing consent without Shopify’s protected customer data approval, and can’t write consent back to Shopify, so unsubscribes are recorded in Littleworks and at the provider.
  • Where the address comes from: the customer’s own submission (confirmed by a double opt-in email before alerts), a Liquid prefill for signed-in shoppers (still untrusted), or a merchant’s Shopify Flow workflow that passes the address and consent as Input fields to a Flow work.
  • A test plan agreed with the merchant: Postmark’s test token accepts sends without delivering them; Resend has documented test addresses; SendGrid has a sandbox mode; Klaviyo needs a test metric and profile.

The works to build

WorkEntry pointBehavior
<feature>.email-setupCallable, run once for realWrite the settings: From, the sending switch and the test recipients
The feature’s own work, such as a quote reply actionWherever the email startsCreate the outbox record with its send key and send it now, or leave it queued
<feature>.email-dispatchSchedule, every 15 to 60 minutesSettle interrupted sends, send queued records in batches, import the provider’s suppressions
<feature>.unsubscribePublic HTTP POST from the storefrontVerify the signed link and write the suppression
<feature>.outbox, <feature>.outbox-statsPage dataThe list (viewPage) and the counts
<feature>.outbox-managePage actionRetry, Send again, Mark handled, Suppress address, Pause and Resume, with revisions

Works can’t call each other, so keep the send function in one module: CLI users bundle it into each work that sends, and MCP-only agents copy it unchanged. A producer can also only enqueue and let the dispatcher send, which is simpler but waits up to one schedule interval. A validated example of every piece is in the Littleworks repository under examples/recipe-email.

Implementation outline for the agent

  1. Render the model, not the message. Each purpose maps to a provider template and a small model: product title, URL and price with its currency from Storefront, or a quote reply excerpt. Escape anything customer-written if the merchant insists on local templates.
  2. Record once. Create the outbox record with the hashed send key as its ID. A conflict means the same send was requested before: report its status and never send it again. This makes repeated calls, duplicate events and double clicks safe.
  3. Gate every send. Check the sending switch, then suppressions for the address hashes ($in takes 20 values a query): an unsubscribe skips subscribed and marketing email, and a bounce or complaint skips everything. Marketing needs consent passed in by the caller. In test runs, send only to the test recipients and skip everyone else with reason test.
  4. Claim before calling. Move records from queued to sending with this run’s ID, in revision-checked batches of 50. Records another run claimed first fail their revision check and are skipped. Never send a record this run didn’t claim.
  5. One provider request per batch. Fill it to about 60 KiB and set timeoutMs. Use Resend’s Idempotency-Key or Klaviyo’s unique_id where available, and carry the send key in Postmark Metadata or SendGrid custom_args. Check response.ok, then each message’s result by position.
  6. Record outcomes. Accepted becomes sent with the provider’s message ID. A refused request (4xx or 5xx) becomes failed with its code; a Postmark 406 (inactive recipient) also writes a bounce suppression. A timeout or network error after the request left becomes unknown, which is never retried automatically; Resend is the exception, where resending with the same idempotency key within 24 hours is safe.
  7. Drain across runs. The dispatcher sends up to five batches, about 500 emails, and stops starting batches after 6 seconds. A longer queue continues next run, oldest first. Records still sending a minute after their run started belong to a run that ended, and become unknown.
  8. Reconcile on the schedule. No webhook is needed. Import Postmark’s suppressions with GET /message-streams/{stream}/suppressions/dump?fromdate=… from the last import (SendGrid: its bounce, spam report and unsubscribe lists; Resend: GET /emails/{id} for recent sends), and advance reconciledThrough.
  9. Sign unsubscribe links. The token is the base64url of {h: SHA-256 of the address, p: purpose, f: feature} and its HMAC-SHA-256 with EMAIL_LINK_KEY. The link opens a storefront page (/pages/email-preferences#t=…) whose section removes the token from the address bar and posts it to the unsubscribe work after a click. A token for another feature is refused. While rotating the key, keep the old value as EMAIL_LINK_KEY_PREVIOUS.
  10. Keep it private. Log counts and codes only, never return addresses from public works, and name the provider in the merchant’s privacy policy as a recipient of customer data.
Sign an unsubscribe token
const encoder = new TextEncoder();
const base64url = bytes => btoa(String.fromCharCode(...new Uint8Array(bytes)))
  .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");

async function unsubscribeToken(secrets, addressHash, purpose) {
  const key = await crypto.subtle.importKey("raw", encoder.encode(await secrets.get("EMAIL_LINK_KEY")),
    { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
  const payload = base64url(encoder.encode(JSON.stringify({ h: addressHash, p: purpose, f: "quotes" })));
  return `${payload}.${base64url(await crypto.subtle.sign("HMAC", key, encoder.encode(payload)))}`;
}

See what was sent

Build a merchant page for the feature’s outbox: a table with to (email), purpose, a status field (sent success, queued and sending info, failed critical, unknown warning, skipped and handled neutral), reason, lastError.code, customerId (customer), sentAt and $createdAt; tabs Needs attention, Queued, Sent, Skipped and All; search on to. Metrics: sent in 7 days, failed, unknown, queued and suppressed addresses. Retry applies to failed records. Send again applies to unknown ones and confirms that it may email twice. Mark handled and Suppress address close a record, and page-level Pause sending and Resume sending flip the switch. The page uses one published page slot (Starter has 2).

What it costs to run

PatternRuns
Quote replies, 200 a month, sent inside the reply actionNo extra runs: the email is part of the action’s run
Back-in-stock, 2,000 notified a monthSent inside the scheduled runs, about 500 per run
Dispatcher every 15 minutes2,880 a month (hourly 720; every 5 minutes 8,640, most of Starter’s 10,000)
Flow review requests, 300 a monthAbout 300

Documents: one per outbox record until it expires, one per suppression, and the settings. External requests are included on every plan, up to 50 per run; the provider bills its own emails. Choose the least frequent schedule that keeps the promise, such as “alerts within an hour”.

Acceptance checks

  • The same send twice, a duplicate Shopify event, a double click or overlapping runs produce one outbox record and one provider message.
  • A timeout after the provider may have accepted becomes unknown and isn’t resent automatically.
  • Suppressed, unsubscribed, bounced or non-consenting addresses are skipped with a recorded reason. A tampered token, or one from another feature, changes nothing.
  • Test runs never email anyone outside the test recipients, and use the test key. No address or key appears in logs, errors or public responses.
  • A queue of more than one batch completes over scheduled runs, oldest first, within the per-run limits. A paused dispatcher holds the queue and sends it after resuming.
  • The page shows failures, and Retry respects revisions and suppressions. A provider outage leaves the quote saved and the reply marked failed; the customer’s request never fails because email did.

Make it yours

Send SMS through the same module, add a preference page per purpose, send digests on a schedule, or confirm alert signups with a double opt-in, as the back-in-stock recipe does. Not provided: a mail service, inbound email, attachments beyond the 64 KiB request body, provider webhook signature checks (works receive the JSON body without headers), and an email log across features.