SendboundSendbound

Migrate from Postmark

Step-by-step guide to moving from Postmark to Sendbound. API mapping, message stream mapping, template porting, and webhook translation.

This guide covers everything specific to migrating from ActiveCampaign Postmark.

Before starting, read the general migration guide for the parallel-run strategy and DNS playbook.


What's different

PostmarkSendbound
API basehttps://api.postmarkapp.comhttps://api.sendbound.com/v1
Auth headerX-Postmark-Server-Token: xxxAuthorization: Bearer sk_live_xxx
Send endpointPOST /emailPOST /send
Template syntaxMustache {{variable}}Handlebars {{variable}} ✓ Compatible
Message streamsOutbound / BroadcastsTransactional send / Campaigns
Template callPOST /email/withTemplatePOST /send with templateId
Bounce typesHardBounce, SoftBounce, etc.bounced with reason string

Export from Postmark

Export templates via API

Postmark has no UI bulk export. Use their API:

# List all templates
curl -H "X-Postmark-Server-Token: YOUR_SERVER_TOKEN" \
  https://api.postmarkapp.com/templates

# Fetch individual template
curl -H "X-Postmark-Server-Token: YOUR_SERVER_TOKEN" \
  https://api.postmarkapp.com/templates/TEMPLATE_ID

The response includes HtmlBody and TextBody fields — save these as HTML files.

Export suppression list

curl -H "X-Postmark-Server-Token: YOUR_SERVER_TOKEN" \
  "https://api.postmarkapp.com/suppressions/dump?MessageStream=outbound" \
  > suppressions.csv

Export contacts

Postmark doesn't have a contact database for Broadcasts users built in. Export your subscriber list from your own database or from the Broadcasts UI if available.

Note webhook (delivery webhook) configuration

Server Settings → Webhooks tab — note which events are enabled and the endpoint URL for each stream.


Message stream mapping

Postmark separates transactional (Outbound stream) and broadcast (Broadcasts stream) email. Sendbound handles both:

Postmark streamSendbound equivalent
Outbound (transactional)POST /v1/send
BroadcastsCampaigns

API migration

Sending a transactional email

import * as postmark from 'postmark';

const client = new postmark.ServerClient(process.env.POSTMARK_SERVER_TOKEN!);

await client.sendEmail({
  From: 'hello@yourdomain.com',
  To: 'user@example.com',
  Subject: 'Welcome',
  HtmlBody: '<p>Welcome aboard!</p>',
  MessageStream: 'outbound',
});
await fetch('https://api.sendbound.com/v1/send', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.SENDBOUND_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    to: 'user@example.com',
    from: 'hello@yourdomain.com',
    subject: 'Welcome',
    html: '<p>Welcome aboard!</p>',
  }),
});

Using a stored template

await client.sendEmailWithTemplate({
  From: 'hello@yourdomain.com',
  To: 'user@example.com',
  TemplateAlias: 'welcome',
  TemplateModel: {
    product_name: 'Acme',
    user_name: 'Jane',
    action_url: 'https://app.example.com/confirm/xyz',
  },
  MessageStream: 'outbound',
});
await fetch('https://api.sendbound.com/v1/send', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.SENDBOUND_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    to: 'user@example.com',
    from: 'hello@yourdomain.com',
    templateId: 'tmpl_your_id',
    variables: {
      product_name: 'Acme',
      user_name: 'Jane',
      action_url: 'https://app.example.com/confirm/xyz',
    },
  }),
});

Template migration

Postmark uses Mustache ({{variable}}). Sendbound uses Handlebars which is a superset — all Mustache templates work unchanged.

PostmarkSendboundNotes
{{variable}}{{variable}}✓ No change
{{#if condition}}{{#if condition}}✓ No change
{{{html_variable}}}{{{html_variable}}}✓ Triple braces for unescaped HTML work
{{product.url}} (Postmark's action block)Inline anchor tagReplace action blocks with plain HTML buttons

Postmark's "Action" layout blocks (buttons, call-to-action sections) are proprietary template components. Export the rendered HTML from Postmark's template preview and import that directly — you get the same visual output without the proprietary syntax.


Webhook migration

Event name mapping

Postmark eventSendbound event
Deliveryemail.delivered
Openemail.opened
Clickemail.clicked
Bounce (HardBounce)email.bounced
Bounce (SoftBounce)email.bounced (with reason)
SpamComplaintemail.complained
SubscriptionChangeemail.unsubscribed

Signature verification

// Postmark uses a shared secret in the header
// but does not sign the payload body
const webhookSecret = process.env.POSTMARK_WEBHOOK_TOKEN;
if (req.headers['x-postmark-webhook-token'] !== webhookSecret) {
  return res.status(401).send('Unauthorized');
}
import crypto from 'crypto';

function verifyWebhook(payload: string, signature: string, secret: string) {
  const expected = crypto.createHmac('sha256', secret).update(payload).digest('hex');
  return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}

const isValid = verifyWebhook(
  rawBody,
  req.headers['x-sendbound-signature'] as string,
  process.env.SENDBOUND_WEBHOOK_SECRET!,
);
if (!isValid) return res.status(401).send('Unauthorized');

Checklist

  • Exported all templates via Postmark API
  • Exported suppression list (both streams)
  • Noted which templates are Outbound vs Broadcasts
  • Added and verified sending domain in Sendbound
  • Imported suppression lists
  • Imported templates (replaced Postmark action blocks with HTML)
  • Updated API calls (removed MessageStream, swapped header)
  • Configured Sendbound webhook for delivery events
  • Updated webhook signature verification
  • Ran parallel test
  • Cut over
  • Revoked Postmark server token