Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

## Unreleased

- All six server SDKs expose optional automatic-pause `reason`/`code` and
optional recipient-skip `messageIndex`/`followUpId` from accepted API source
`9bb1e2ed`. Future reason/code strings and raw envelope fields are retained;
manual omission and zero sequence positions are preserved. Pause timestamps
remain seconds, skip timestamps milliseconds. Rust full struct literals and
Go unkeyed literals need the added optional fields; C# constructor/Deconstruct
arities are unchanged. Source-only and unpublished; see
`docs/campaign-webhook-metadata.md` for compatibility and acceptance gates.
- Prepare simultaneous-call settings and Ban Insurance evidence in all six
server SDKs against accepted API #559 source `c1a43a1e`. Updates preserve
omission and explicit false. The API enforces acknowledgment and team-key
Expand Down
14 changes: 14 additions & 0 deletions contracts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -637,6 +637,20 @@ from API `dev` merge commit `8d248b578a7c76034b30a5a97349adcd15ce5c55`
(polymorfa/polymorfa#406), with both source paths and file hashes.
`campaign-ab-tests-contract.test.ts` checks the SDK types against it.

## Campaign webhook metadata supplement

`campaign-paused-reasons.json` pins the two accepted webhook schemas at API
`9bb1e2ed92d8b4a07ff727f8e7ae185498f0868f`, with the exact Messaging spec hash.
The original pause producer is API326 `a6812f1570814346d5e2a973ed1b855026981793`.
Pause `reason`/`code` remain optional open strings. The accepted recipient-skip
schema also includes optional cap `messageIndex`/`followUpId`; the old326 full
skip schema is not claimed equivalent. Pause timestamps are seconds and skip
timestamps milliseconds. `webhook-contract.test.ts` overlays only these scoped
schemas; all-six source/test attribution is in
`native-campaign-webhook-metadata.json`. Compatibility and source-only package
examples are in `docs/campaign-webhook-metadata.md`. Full baseline snapshots,
coverage operation pins and API date remain unchanged.

## Campaign follow-up supplement

`campaign-follow-ups.json` records the receipt-timed follow-up schemas
Expand Down
101 changes: 101 additions & 0 deletions contracts/campaign-paused-reasons.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
{
"repository": "polymorfa/polymorfa",
"commit": "9bb1e2ed92d8b4a07ff727f8e7ae185498f0868f",
"note": "Scoped accepted pause/recipient-skip metadata, including optional frequency-cap sequence and follow-up fields. Original pause producer is API326. Full baseline snapshots/API version remain unchanged; source acceptance is not environment availability or publication.",
"sources": {
"messaging": {
"sourcePath": "apps/api/docs/openapi.json",
"sourceSha256": "6d5b5477a86a85504f921933bf7b26f2c47506e21380fe73c8a5ff1002163b28",
"schemas": {
"CampaignPausedPayload": {
"type": "object",
"properties": {
"campaignId": {
"type": "string",
"description": "Campaign id",
"example": "01936b2e-5f2a-7c1d-9a3e-8b4c2d1e0f11"
},
"sentCount": {
"type": "integer",
"minimum": 0,
"description": "Recipients sent so far"
},
"remainingCount": {
"type": "integer",
"minimum": 0,
"description": "Recipients still queued"
},
"pausedAt": {
"type": "integer",
"minimum": 0,
"description": "Unix timestamp in seconds when the campaign paused",
"example": 1757161200
},
"reason": {
"type": "string",
"description": "Why Polymorfa paused the campaign. Present only on an automatic pause.",
"example": "WhatsApp lowered the messaging limit of a number this campaign sends from."
},
"code": {
"type": "string",
"description": "Stable code for an automatic pause: campaigns_not_entitled, bansafe_org_suspended, bansafe_suspended, campaign_throughput_capped, invalid_parameter, hybrid_campaign_unsupported, blocked_by_safety, number_quality_dropped, or messaging_limit_dropped. Present only on an automatic pause.",
"example": "messaging_limit_dropped"
}
},
"required": ["campaignId", "sentCount", "remainingCount", "pausedAt"]
},
"CampaignRecipientSkippedPayload": {
"type": "object",
"properties": {
"campaignId": {
"type": "string",
"description": "Campaign id",
"example": "01936b2e-5f2a-7c1d-9a3e-8b4c2d1e0f11"
},
"recipientId": {
"type": "string",
"description": "Campaign recipient id",
"example": "01936b2e-6a11-7d2e-8f4a-1c2b3d4e5f66"
},
"phone": {
"type": "string",
"description": "Recipient phone number in E.164",
"example": "+14155550123"
},
"reason": {
"type": "string",
"description": "Stable skip reason: optout, frequency_cap_exceeded for a campaign contact cap, or whatsapp_marketing_limit when WhatsApp withheld a marketing message because the person reached their per-user marketing message limit",
"example": "whatsapp_marketing_limit"
},
"messageIndex": {
"type": "integer",
"minimum": 0,
"description": "Position from 0 of a capped sequence message. Above 0, the initial send status is preserved and remaining sequence messages stop.",
"example": 1
},
"followUpId": {
"type": "string",
"format": "uuid",
"description": "Receipt-timed follow-up cancelled by the contact frequency cap; the initial send status is preserved.",
"example": "0198a2cb-0000-7000-8000-0000000000f1"
},
"skippedAt": {
"type": "integer",
"minimum": 0,
"description": "Unix timestamp in milliseconds when the recipient was skipped",
"example": 1757161200000
}
},
"required": [
"campaignId",
"recipientId",
"phone",
"reason",
"skippedAt"
]
}
}
}
},
"introducedBy": "a6812f1570814346d5e2a973ed1b855026981793"
}
4 changes: 2 additions & 2 deletions contracts/doc-snippets.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"schemaVersion": 1,
"sourceCommit": "ef1b2eb1b8bb64ce40dbb115d9f1cbb180ad3515",
"sourceFilesSha256": "77ac5e4f0867d06ee5f0e2b06b79aebdd533d2c6a0d8219222f3225c068bab53",
"sourceCommit": "32c75bd12f7686ee4890099c29df415f1c8a9c3e",
"sourceFilesSha256": "f9eeb2b51d8d8ec656d587bdef2998dbed11cd204fd2c3343625819dd02b3281",
"apiVersion": "2026-09-22",
"apiSourceCommit": "171a9683ff45d11a4b5d3917dc428ad2722b0d99",
"specs": {
Expand Down
78 changes: 78 additions & 0 deletions contracts/fixtures/campaign-webhook-metadata.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
{
"source": "9bb1e2ed92d8b4a07ff727f8e7ae185498f0868f",
"secret": "campaign-fixture-only",
"batchId": "00000000-0000-4000-8000-000000000001",
"events": [
{
"id": "auto",
"session": "",
"timestamp": "2026-10-22T00:00:00Z",
"event": "campaign.paused",
"payload": {
"campaignId": "01936b2e-5f2a-7c1d-9a3e-8b4c2d1e0f11",
"sentCount": 3,
"remainingCount": 7,
"pausedAt": 1757161200,
"reason": "Automatic safety pause",
"code": "future_automatic_pause",
"future": {
"kept": true
}
}
},
{
"id": "manual",
"session": "",
"timestamp": "2026-10-22T00:00:00Z",
"event": "campaign.paused",
"payload": {
"campaignId": "01936b2e-5f2a-7c1d-9a3e-8b4c2d1e0f11",
"sentCount": 3,
"remainingCount": 7,
"pausedAt": 1757161200
}
},
{
"id": "sequence",
"session": "",
"timestamp": "2026-10-22T00:00:00Z",
"event": "campaign.recipient_skipped",
"payload": {
"campaignId": "01936b2e-5f2a-7c1d-9a3e-8b4c2d1e0f11",
"recipientId": "01936b2e-6a11-7d2e-8f4a-1c2b3d4e5f66",
"phone": "+14155550123",
"reason": "frequency_cap_exceeded",
"skippedAt": 1757161200000,
"messageIndex": 0
}
},
{
"id": "followup",
"session": "",
"timestamp": "2026-10-22T00:00:00Z",
"event": "campaign.recipient_skipped",
"payload": {
"campaignId": "01936b2e-5f2a-7c1d-9a3e-8b4c2d1e0f11",
"recipientId": "01936b2e-6a11-7d2e-8f4a-1c2b3d4e5f66",
"phone": "+14155550123",
"reason": "frequency_cap_exceeded",
"skippedAt": 1757161200000,
"messageIndex": 1,
"followUpId": "0198a2cb-0000-7000-8000-0000000000f1"
}
},
{
"id": "plain",
"session": "",
"timestamp": "2026-10-22T00:00:00Z",
"event": "campaign.recipient_skipped",
"payload": {
"campaignId": "01936b2e-5f2a-7c1d-9a3e-8b4c2d1e0f11",
"recipientId": "01936b2e-6a11-7d2e-8f4a-1c2b3d4e5f66",
"phone": "+14155550123",
"reason": "future_skip_reason",
"skippedAt": 1757161200000
}
}
]
}
37 changes: 37 additions & 0 deletions contracts/native-campaign-webhook-metadata.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"apiSource": "9bb1e2ed92d8b4a07ff727f8e7ae185498f0868f",
"contract": "contracts/campaign-paused-reasons.json",
"note": "Field supplement only: no added/removed operations, so baseline operation coverage and source pins are unchanged. Default test registration is required; listed source paths are not execution proof.",
"languages": {
"typescript": {
"models": "packages/typescript/src/webhooks/events.ts",
"test": "packages/typescript/test/campaign-webhook-metadata.test.ts",
"default": "vitest run"
},
"go": {
"models": "packages/go/webhook_payloads.go",
"test": "packages/go/campaign_webhook_metadata_test.go",
"default": "go test ./..."
},
"python": {
"models": "packages/python/src/polymorfa/webhook_models.py",
"test": "packages/python/tests/test_campaign_webhook_metadata.py",
"default": "pytest"
},
"php": {
"models": "packages/php/src/WebhookModels.php",
"test": "packages/php/tests/campaign_webhook_metadata.php",
"default": "tests/run.php includes campaign_webhook_metadata.php"
},
"rust": {
"models": "packages/rust/src/webhook_payloads.rs",
"test": "packages/rust/tests/campaign_webhook_metadata.rs",
"default": "cargo test integration discovery"
},
"dotnet": {
"models": "packages/dotnet/src/Polymorfa.Sdk/WebhookPayloads.cs",
"test": "packages/dotnet/tests/Polymorfa.Sdk.Tests/CampaignWebhookMetadataTests.cs",
"default": "tests/Polymorfa.Sdk.Tests/Program.cs calls CampaignWebhookMetadataTests.Run()"
}
}
}
54 changes: 54 additions & 0 deletions docs/campaign-webhook-metadata.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Campaign webhook metadata (unreleased source)

This source supplement consumes accepted API source
`9bb1e2ed92d8b4a07ff727f8e7ae185498f0868f`, containing the original API326 pause
producer. It does not change the baseline API date `2026-09-22` or baseline
snapshots. Source acceptance does not establish publication or environment availability.

| Event | Optional field | Contract | Timestamp unit |
| ---------------------------- | ---------------- | ------------------------------------------------------------------- | ------------------------------ |
| `campaign.paused` | `reason`, `code` | Strings when present, automatic pauses only; future codes stay open | `pausedAt`: Unix seconds |
| `campaign.recipient_skipped` | `messageIndex` | Integer >= 0; zero is meaningful | `skippedAt`: Unix milliseconds |
| `campaign.recipient_skipped` | `followUpId` | UUID string identifying a capped follow-up | `skippedAt`: Unix milliseconds |

A manual pause carries neither reason nor code. Skips without sequence or
follow-up metadata omit those fields. Frequency-cap skips can name a sequence
position and a receipt-timed follow-up. Reasons include `frequency_cap_exceeded`
and `whatsapp_marketing_limit`, but consumers must retain unknown future strings.
The required event fields are unchanged. Do not convert timestamps or treat zero
as absence. Ordinary Campaign `pauseReason` is a different field.

All six SDKs expose this metadata through their existing public webhook types.
Go typed projections use optional pointers; Rust uses `Option`; C# uses optional
properties. Python and PHP retain raw payload dictionaries/arrays, and TypeScript
retains raw payload objects. The existing language-specific parser tolerance and
malformed-type policy remain unchanged; the API's optional non-null contract is
not a new blanket SDK validator. Raw envelopes retain future keys even when a
typed projection omits an unknown key. Verify the exact whole body before parsing
any single event or batch member; preserve member order and deduplicate event IDs.

## Source compatibility

C# keeps the existing four-position pause constructor/Deconstruct and five-position
skip constructor/Deconstruct. New optional init properties serialize sparsely.
Rust adds public optional struct fields. Consumers that construct these payloads
with full struct literals must add `reason: None`, `code: None`, or
`message_index: None`, `follow_up_id: None` as appropriate. Existing serialized
payloads without those fields remain decodable. Go unkeyed public struct literals
also need an update; prefer keyed construction. No transport/retry/error code,
security/runtime floor, credential, pause-control, entitlement or feature flag changes.

## Source and validation evidence

`contracts/campaign-paused-reasons.json` pins the exact two accepted schema
fragments and Messaging spec SHA256. The original API326 skip snapshot is not
claimed equivalent to accepted9bb: the accepted sequence and follow-up fields are
included. `contracts/fixtures/campaign-webhook-metadata.json` supplies synthetic
automatic/manual/sequence/follow-up/future-code payloads to default native tests.
Package READMEs show per-language verified-event handling fragments. These are
source-only examples; no registry install command or provider call is claimed.

Fresh exact-source hosted checks, physical consumers, package publication and
matching API environment acceptance remain separate release gates. Public Docs,
CLI and Console must adopt an actual accepted published version before claiming
availability. No customer-facing Docs release is performed by this SDK change.
21 changes: 21 additions & 0 deletions packages/dotnet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1012,3 +1012,24 @@ includes `simultaneousCalls`; the webhook does not acquire an evidence object.

No browser/client-token authority, call media behavior, automatic state read,
retry policy or feature availability is added by these SDK fields.

## Campaign webhook metadata (unreleased source)

Automatic `campaign.paused` payloads can include open-string `reason` and
`code`; manual pauses omit both. `pausedAt` is Unix seconds.
`campaign.recipient_skipped` can include nonnegative `messageIndex` (including
zero) and a UUID `followUpId`. `skippedAt` is Unix milliseconds. Missing optional
fields remain absent. Do not infer a pause cause from an unknown future code.

```csharp
// webhookEvent is verified by Webhooks.ConstructDelivery.
if (webhookEvent is TypedWebhookEvent<CampaignPausedPayload> pause)
Console.WriteLine($"{pause.Payload.Code} {pause.Payload.PausedAt}"); // seconds
if (webhookEvent is TypedWebhookEvent<CampaignRecipientSkippedPayload> skip)
Console.WriteLine($"{skip.Payload.MessageIndex} {skip.Payload.FollowUpId} {skip.Payload.SkippedAt}"); // milliseconds
```

These are verified-event handling fragments, not complete receivers. Preserve
whole-body signature verification before decoding and deduplicate batch members.
See [the scoped source contract and compatibility notes](../../docs/campaign-webhook-metadata.md).
Package publication and matching API environment acceptance remain separate gates.
14 changes: 12 additions & 2 deletions packages/dotnet/src/Polymorfa.Sdk/WebhookPayloads.cs
Original file line number Diff line number Diff line change
Expand Up @@ -99,15 +99,25 @@ public sealed record MessageFailedPayload(IdentityReference To, string Type, str
public sealed record RuntimeTemplateStatusPayload(string TemplateName, string TemplateId, string Status, string Category, string Reason, string QualityRating);
public sealed record CloudTemplateStatusPayload(string Kind, string? Event = null, string? TemplateId = null, string? TemplateName = null, string? Language = null, string? Reason = null, string? PreviousQualityScore = null, string? NewQualityScore = null, string? WabaId = null);
public sealed record CampaignLaunchedPayload(string CampaignId, string Name, int RecipientCount, bool Scheduled, long LaunchedAt);
public sealed record CampaignPausedPayload(string CampaignId, long SentCount, long RemainingCount, long PausedAt);
/// <summary>PausedAt is Unix seconds. Automatic metadata uses open string values.</summary>
public sealed record CampaignPausedPayload(string CampaignId, long SentCount, long RemainingCount, long PausedAt)
{
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Reason { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Code { get; init; }
}
public sealed record CampaignRescheduledPayload(string CampaignId, long? PreviousScheduledAt, long ScheduledAt, long RescheduledAt);
public sealed record CampaignResumedPayload(string CampaignId, long SentCount, long RemainingCount, long ResumedAt);
public sealed record CampaignCompletedPayload(string CampaignId, long SentCount, long DeliveredCount, long ReadCount, long FailedCount, long SkippedCount, long ResponseCount, long CompletedAt, long DurationMs);
public sealed record CampaignFailedPayload(string CampaignId, string Reason, long FailedAt);
public sealed record CampaignStoppedPayload(string CampaignId, long SentCount, long AbandonedCount, long StoppedAt);
public sealed record CampaignRecipientSentPayload(string CampaignId, string RecipientId, string Phone, string SessionKey, string ExternalMessageId, string VariantKey, int Attempt);
public sealed record CampaignRecipientFailedPayload(string CampaignId, string RecipientId, string Phone, int Attempts, string Error, long FailedAt);
public sealed record CampaignRecipientSkippedPayload(string CampaignId, string RecipientId, string Phone, string Reason, long SkippedAt);
/// <summary>SkippedAt is Unix milliseconds. Optional sequence positions include zero.</summary>
public sealed record CampaignRecipientSkippedPayload(string CampaignId, string RecipientId, string Phone, string Reason, long SkippedAt)
{
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public long? MessageIndex { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? FollowUpId { get; init; }
}
public sealed record CampaignThrottledPayload(string CampaignId, string SessionKey, string Reason, long DeferredCount, long At);
public sealed record CampaignCapReachedPayload(string CampaignId, string SessionKey, string Phone, string CapType, long CapLimit, long WindowResetsAt, long At);
public sealed record CampaignColdBlockedPayload(string CampaignId, string RecipientId, string Phone, string Surface, string Reason, long At);
Expand Down
Loading
Loading