Appearance
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 call | 5,000 |
| Custom fields per recipient | 16 |
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:
| Policy | If 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
| Status | code / message | Fix |
|---|---|---|
| 400 | validation_failed | A field is missing or too long; more than 5,000 rows; recipientPolicies without recipients |
| 404 | Template not found | Wrong templateId |
| 422 | recipient_validation_failed | Read details.issues |
| 422 | list_empty | Every row was skipped by policy |
| 403 | forbidden | Key lacks campaigns:create |
Next
- Campaign from a CSV or Excel file — for exports
- Lists & recipients — the schema model
- Idempotency