Skip to content

Campaign with inline recipients ​

Send the audience with the request: one call, no list management. The pattern for a system that already holds its own addresses.

Permissions needed: templates:read, campaigns:create, campaigns:read. Notably not lists:* — the list is created for you.

The call ​

bash
curl -X POST "$DRSE_BASE_URL/api/v1/campaigns" \
  -H "x-api-key: $DRSE_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Renewal reminders — July",
    "templateId": "550e8400-e29b-41d4-a716-446655440002",
    "recipients": [
      { "name": "Alice Andersson", "street": "1 First St",  "postCode": "111 11", "postTown": "Stockholm", "country": "SE" },
      { "name": "Bo Bergström",    "street": "2 Second Rd", "postCode": "222 22", "postTown": "Göteborg",  "country": "SE" }
    ]
  }'
ts
type Recipient = {
  name: string;
  street: string;
  postCode: string;
  postTown: string;
  country: string;
  customFields?: Record<string, { label: string; value: string }>;
};

const recipients: Recipient[] = customers.map((c) => ({
  name: c.fullName,
  street: c.addressLine,
  postCode: c.zip,
  postTown: c.city,
  country: c.countryCode,
}));

const res = await fetch(`${BASE}/api/v1/campaigns`, {
  method: "POST",
  headers: {
    "x-api-key": KEY,
    "Idempotency-Key": idempotencyKey,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    name: "Renewal reminders — July",
    templateId: "550e8400-e29b-41d4-a716-446655440002",
    recipients,
  }),
});

const body = await res.json();
if (!res.ok) throw new Error(`${body.error.code}: ${body.error.message}`);
console.log(body.data.import); // { imported: 2, skipped: 0 }

201 Created

json
{
  "data": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "name": "Renewal reminders — July",
    "status": "pending",
    "progress": 0,
    "listId": "550e8400-e29b-41d4-a716-446655440001",
    "listName": "Renewal reminders — July",
    "templateId": "018f2c7a-0000-7000-8000-000000000000",
    "publishedAt": null,
    "createdAt": "2026-07-25T10:00:00.000Z",
    "updatedAt": "2026-07-25T10:00:00.000Z",
    "qrCode": null,
    "import": { "imported": 2, "skipped": 0 }
  }
}

The listId is a real list named after the campaign. It appears in GET /api/v1/lists and can be reused later.

Verify import.imported

Compare it against the number of rows you sent. For inline recipients they should always match — skipped is always 0 here, because a row you named explicitly is either accepted or reported as an error, never silently dropped.

Limits ​

Rows per call5,000
Custom fields per recipient16

For more than 5,000, import into a list and send with listId, or split across campaigns.

Personalising with custom fields ​

Custom fields fill placeholders in the template.

json
{
  "name": "Renewal reminders — July",
  "templateId": "550e8400-e29b-41d4-a716-446655440002",
  "recipients": [
    {
      "name": "Alice Andersson",
      "street": "1 First St",
      "postCode": "111 11",
      "postTown": "Stockholm",
      "country": "SE",
      "customFields": {
        "renewal_date": { "label": "Renewal date", "value": "2026-09-01" },
        "plan": { "label": "Plan", "value": "Pro" }
      }
    }
  ],
  "recipientPolicies": {
    "renewal_date": { "kind": "fail" },
    "plan": { "kind": "default", "value": "Standard" }
  }
}

The implicit list's schema is derived from the union of custom-field keys across all rows, and recipientPolicies decides what happens when a row omits one:

PolicyIf a row omits the field
{ "kind": "skip" }Drop the row (the default when unspecified)
{ "kind": "fail" }Reject the whole call with 422
{ "kind": "default", "value": "..." }Substitute the value

skip is the default, and it drops rows silently

An unspecified policy means a row missing that field is quietly excluded — the call still succeeds, just with fewer letters. Set fail for fields that must be present, and always check import.imported.

recipientPolicies is only valid with recipients; sending it otherwise is a 400.

All-or-nothing ​

Every row is validated before anything is written. One bad row means 422 and nothing created — no list, no recipients, no campaign.

json
{
  "error": {
    "code": "recipient_validation_failed",
    "message": "Recipient validation failed",
    "details": {
      "issues": [
        { "row": 2, "field": "postTown", "issue": "missing_required", "message": "..." }
      ]
    }
  }
}

row is the 1-based position in the array you sent, so row: 2 is recipients[1].

ts
if (res.status === 422) {
  for (const issue of body.error.details.issues) {
    const bad = recipients[issue.row - 1];
    console.error(`Row ${issue.row} (${bad?.name}): ${issue.message}`);
  }
}

Fix and resend the whole call — with the same Idempotency-Key, since nothing was created.

Deduplicate before sending ​

Nothing dedupes addresses. Two identical rows mean two letters to the same letterbox.

ts
const seen = new Set<string>();
const unique = recipients.filter((r) => {
  const key = `${r.name}|${r.street}|${r.postCode}`.toLowerCase();
  if (seen.has(key)) return false;
  seen.add(key);
  return true;
});

Failure modes ​

Statuscode / messageFix
400validation_failedA field is missing or too long; more than 5,000 rows; recipientPolicies without recipients
404Template not foundWrong templateId
422recipient_validation_failedRead details.issues
422list_emptyEvery row was skipped by policy
403forbiddenKey lacks campaigns:create

Next ​