Comment-to-DM production guide
Build a reliable keyword-to-DM pipeline with durable ingestion, campaign scoping, deduplication, cooldowns and safe recovery.
Use this guide when a keyword campaign must survive duplicate webhooks, worker crashes and uncertain network outcomes. Start with Comment-to-DM for setup and platform limits.
The pipeline is:
- Verify and validate the webhook.
- Resolve the authorized tenant/account mapping.
- Commit an event and job durably, then acknowledge.
- Match the campaign and atomically reserve the comment and any person-level cooldown.
- Send the private DM and persist the result.
- Optionally publish a public confirmation after a confirmed send.
- Process the person's reply separately through
message.received.
The storage methods below are application pseudocode, not Outstand SDK methods. Implement them with transactions, unique constraints and a durable queue or transactional outbox.
Campaign rules: keyword, account and post
Store a campaign's tenant, Outstand organization, connected account, network, keyword mode, post scope, template version, active period and cooldown policy. Resolve credentials server-side from your authorized mapping, not from a payload-selected tenant or an untrusted client.
orgId identifies an Outstand organization. Your application's tenants may share an organization or use several organizations. Do not equate orgId with tenant identity. The same platform account can also be connected in more than one Outstand organization. Design one sending owner if those connections would otherwise run competing campaigns.
Choose a post scope explicitly:
- Single-post campaign: match
network,accountIdandplatformPostId. - Account-wide campaign: match the authorized account and network, then keyword. This can reach comments on future posts too.
- Outstand-post campaign: match
postIdonly when you deliberately want a known Outstand record. A nullpostIdis normal for native posts without a matching record.
Missing platformPostId should fail closed for a single-post rule. Never fall back silently to account-wide matching. Store IDs as strings, not JavaScript numbers.
Decide whether keywords use exact text, whole tokens or substrings. Normalize whitespace and case; do not let LINK match blink accidentally. Specify language handling, punctuation, multiple keyword priority and opt-out behavior. Freeze the chosen rule/template version in the job so a retry does not send a newly edited offer.
function matchesCampaign(data, campaign) {
if (data.orgId !== campaign.orgId || data.accountId !== campaign.accountId || data.network !== campaign.network) return false;
if (campaign.platformPostId !== null && data.platformPostId !== campaign.platformPostId) return false;
if (campaign.topLevelOnly && data.parentCommentId !== null) return false;
if (typeof data.text !== 'string') return false;
const tokens = data.text.normalize('NFKC').toLocaleLowerCase('en').match(/[\p{L}\p{N}_]+/gu) ?? [];
return tokens.includes(campaign.keyword.normalize('NFKC').toLocaleLowerCase('en'));
}This example supports a single-token keyword. Define a separate exact-phrase rule for multiword keywords. topLevelOnly is a campaign preference. Facebook private replies are not universally limited to top-level comments: Meta explicitly includes comments on comments.
Durable ingestion before acknowledgement
Configure a webhook signing secret and verify X-Outstand-Signature over the unchanged raw bytes, using constant-time comparison. See signature verification. Validate the envelope and field types before resolving a route.
async function receiveWebhook(request, store) {
const raw = await request.text();
// Application helper: HMAC-SHA256, sha256= prefix, constant-time comparison.
if (!verifyOutstandSignature(raw, request.headers.get('X-Outstand-Signature'))) {
return new Response('Invalid signature', { status: 401 });
}
const event = validateWebhook(JSON.parse(raw));
if (event.event !== 'comment.received') return new Response(null, { status: 204 });
const route = await resolveAuthorizedRoute(event.data);
if (!route) return new Response('Unknown account mapping', { status: 403 });
const data = event.data;
const key = JSON.stringify([route.tenantId, data.orgId, data.network, data.accountId, data.commentId]);
// One transaction: unique inbox key + queued job/outbox row, or a durable broker acknowledgement.
// A duplicate must leave an existing durable job intact, not create another send.
await store.recordEventAndJobOnce({ key, tenantId: route.tenantId, event });
return new Response(null, { status: 204 });
}Return a non-2xx if validation, mapping or durable persistence fails. Catch malformed JSON as a validation error in your HTTP adapter. Monitor rejected mappings so configuration problems do not become silent drops. Once a previously accepted event has a durable job, duplicate deliveries may return 2xx without enqueuing another send.
Do not acknowledge first and then rely on an in-memory promise. With an outbox, commit the job and outbox row together; let a dispatcher publish and retry the outbox record. With a direct broker, await durable acceptance before acknowledging. These approaches protect ingestion, not exactly-once platform delivery.
Comment deduplication is not a person cooldown
Use an atomic unique key across tenant, Outstand organization, network, connected account and commentId. Keep campaign ID out of the send-reservation key when overlapping campaigns must not send twice to one comment. Select a deterministic winning campaign first.
That prevents replaying the same comment. It does not prevent one person posting "LINK" on several posts. A per-person cooldown is a separate atomic reservation, for example:
comment key: [tenantId, orgId, network, accountId, commentId]
cooldown key: [tenantId, orgId, network, accountId, authorId, campaignGroup]Use stable scoped IDs, not display names. Facebook's comment author ID can support a comment-stage cooldown, but is not the Messenger participant ID. If the author ID is unavailable, explicitly skip the campaign or disable person-level cooldown for that event; do not collapse all unknown people onto one key.
Reserve eligibility and cooldown atomically before sending, with concurrency-safe leases. Retain the reservation while a send is unresolved. Decide whether a definite rejection releases the cooldown. Keep a successful comment's tombstone long enough that delayed redelivery cannot create another send. X has no platform one-reply guard, making application deduplication especially important.
Durable job states and retries
The following are your application's job states, not Outstand message status values:
| State | Meaning and next action |
|---|---|
queued | Persisted and eligible for a worker claim. |
processing | Claimed by one worker with a lease; record attempt and selected campaign before the network call. |
sent | A successful response and its conversation/message IDs were persisted. Never resend this job. |
already_contacted | Meta 409 or equivalent evidence. Stop sending; it does not prove this template was delivered. |
retry_wait | Evidence establishes a retryable failure without an accepted send. Store cause, attempt count and next attempt time. |
unknown_outcome | A send may have occurred. Reconcile before any further attempt. |
skipped / failed_terminal | Rule mismatch, cooldown, expired eligibility, invalid target or unrecoverable configuration. Store the reason. |
A worker can crash after the platform accepts a message but before your sent transaction commits. A lease expiring in processing must not automatically put that job back on the send path. Treat it as unknown_outcome unless you can establish the call never started. A lease prevents competing workers; it cannot undo an in-flight network request.
Record response status, sanitized error, attempt time, account/comment IDs and any conversation/message/platform message IDs. Do not log API keys or unnecessary message content. Reconcile against conversation messages, relevant message.sent events and platform inbox evidence. Use the mapped conversation where known, or list conversations for the authorized account. No matching local record is not proof the platform did not send.
There is no documented idempotency-key contract on this start-conversation endpoint. An application job ID or custom request header does not make the remote send idempotent. Instagram has local comment-reply protection and Facebook can reject an already-used private reply, but neither eliminates every transport or post-send persistence ambiguity. X can send a second DM on retry.
Classify the cause, not just the HTTP status
201: persist IDs and marksent. Only then schedule downstream confirmation work.409: markalready_contacted. Do not claim your offer was sent without supporting evidence.400,401,403,404,422: inspect the error and fix the request, access, target or window. Do not loop unchanged requests.429: use retry guidance and bounded exponential backoff with jitter only when the response establishes a rejected attempt. Honor platform windows and your campaign expiry.502, timeout, connection loss or5xxafter a send began: distinguish credential/lookup failure from send failure and post-send storage failure. If acceptance is uncertain, markunknown_outcome, notretry_wait.
For example, Facebook can send the message but return no recipient ID, causing Outstand to report 502 without a stored conversation. That is not a safe automatic retry. A permissions error needs a reconnect or app configuration fix, not backoff. Retry confirmed transient failures with a maximum attempt count and dead-letter/manual review path; do not let an expired campaign send days later.
Send from the worker
After the durable claim, matching and reservations, construct the platform target:
function buildPrivateReply(data, content) {
const body = { social_account_id: data.accountId, content };
if (data.network === 'instagram' && data.authorId) {
return { ...body, instagram: { comment_id: data.commentId, author_id: data.authorId } };
}
if (data.network === 'facebook') return { ...body, facebook: { comment_id: data.commentId } };
if (data.network === 'x') return { ...body, x: { comment_id: data.commentId } };
throw new Error('Unsupported network or missing Instagram author ID');
}
const response = await fetch('https://api.outstand.so/v1/conversations', {
method: 'POST',
headers: {
Authorization: `Bearer ${authorizedAccount.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(buildPrivateReply(job.event.data, job.template.content)),
});
// Persist and classify the response using the state machine above.
// A thrown fetch or unreadable response after sending is an uncertain outcome.Keep the API key on the server and verify it can send conversations for that account. The campaign matches a comment, not consent for an unrestricted message sequence. Meta's limits still apply even if your worker is otherwise eligible to run.
DM first, public confirmation second
If you promise "Sent you the guide", only post it after a confirmed private send. Put public confirmation in a separate durable job, uniquely linked to the successful DM job. If confirmation fails, retry or review the confirmation only; never resend the DM to repair a public comment.
For posts supported by Publish a comment, the request can target the original comment:
curl -X POST https://api.outstand.so/v1/posts/OUTSTAND_POST_ID/replies \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"content": "Sent a DM. Please check your message requests too.",
"platform_post_id": "17912345678901234",
"parent_comment_id": "17865799348089039"
}'That route expects an Outstand post ID and a supported published post/account, plus interactions:reply access. A native post with postId: null can receive a private reply but cannot simply be substituted into this path. Omit the public confirmation when the public-reply API cannot target the post. Check network-specific public-comment support separately from DM support.
Public confirmations can also have uncertain outcomes. Persist their response IDs and avoid duplicate comments after a timeout. Do not publish success for unknown_outcome or an unexplained 409. "Sent" does not mean seen, read, clicked or converted.
Replies, observability and launch checks
Handle message.received as another durable, deduplicated workflow, keyed by organization and messageId. Correlate through the saved conversationId; do not assume a new conversation for each campaign comment. Follow-up requests use /v1/conversations/{id}/messages, not another start-conversation request. Meta's 24-hour window is based on the person's inbound response, not your last outgoing message. URL clicks are not inbound responses.
Before launch, use controlled accounts to verify:
- A fresh comment on a native post matches the intended
platformPostIdeven withpostId: null. - A duplicate webhook and two concurrent workers produce one reserved send.
- A second comment from the same person follows your chosen cooldown policy.
- An unmatched keyword, wrong account and nested comment follow explicit rules.
- A storage outage does not receive a successful webhook acknowledgement.
- A worker crash or send timeout enters reconciliation rather than automatic resend.
- The first private reply, actual inbound reply and 24-hour follow-up path work independently.
- A failed public confirmation never replays the DM.
Monitor received events, persistence failures, queued age, matching skips, cooldown skips, accepted sends, already-contacted results, terminal errors and unknown outcomes. Track inbound replies and conversions separately from sends. See troubleshooting for evidence to collect at each stage.