Webhooks
Receive real-time notifications for events in your Outstand account with webhooks instead of polling the API.
Webhooks allow you to receive real-time notifications when events occur in your Outstand account. Instead of polling our API for updates, webhooks push data to your endpoint as soon as something happens.
Overview
When you configure a webhook endpoint, Outstand will send an HTTP POST request to your specified URL whenever a subscribed event occurs. This enables you to:
- Get instant notifications when posts are published successfully
- Be alerted immediately when publishing errors occur
- Know when a social account token expires and needs re-authentication
- React to new comments on your connected accounts' posts
- Build automated workflows based on Outstand events
- Integrate with your existing systems and dashboards
Setting Up Webhooks
Creating a Webhook Endpoint
- Navigate to Settings in the Outstand web application
- Click on Webhooks in the sidebar
- Click the Add Webhook button
- Fill in the webhook configuration form:
| Field | Description | Required |
|---|---|---|
| Name | A friendly name to identify this webhook (e.g., "Production Notifications") | Yes |
| Endpoint URL | The HTTPS URL where Outstand will send webhook payloads | Yes |
| Signing Secret | A secret key used to sign payloads with HMAC-SHA256 | No |
| Custom Headers | Additional HTTP headers to include in webhook requests | No |
| Events | Which events should trigger this webhook | Yes |
- Select the events you want to subscribe to
- Click Create to save the webhook
Managing Webhooks
From the Webhooks settings page, you can:
- Enable/Disable a webhook using the toggle switch
- Edit a webhook's configuration by clicking the Edit button
- Test a webhook to verify your endpoint is working
- Delete a webhook when it's no longer needed
Available Events
| Event | Description |
|---|---|
post.published | Triggered when a post is successfully published to at least one social account |
post.error | Triggered when a post fails to publish to all targeted social accounts |
account.token_expired | Triggered when a social account's OAuth token fails to refresh |
import.completed | Triggered when a post import job finishes successfully |
import.failed | Triggered when a post import job finishes with errors or partial results |
conversation.started | Triggered when a new conversation starts with a connected account, whether the contact messages first or you start it |
message.received | Triggered when a direct message is received |
message.sent | Triggered when a direct message is sent from a connected account |
message.failed | Triggered when a direct message fails to send |
comment.received | Triggered when someone comments on a connected account's post |
The conversation.* and message.* events are emitted for Instagram Direct Messages, and only for accounts connected with the instagram_business_manage_messages scope explicitly requested (see Enable Direct Messages).
The comment.received event is emitted for Instagram comments and Threads replies.
Webhook Payload Format
All webhook payloads are sent as JSON with the following structure:
Headers
Content-Type: application/json
User-Agent: Outstand-Webhooks/1.0
X-Outstand-Signature: sha256=<signature> (if signing secret is configured)Plus any custom headers you configured.
post.published Event
Sent when a post is successfully published to at least one social account.
{
"event": "post.published",
"timestamp": "2024-12-29T10:30:00.000Z",
"data": {
"postId": "9dyJS",
"orgId": "kR7mQ2xLpN4vT8wZ3bC6yH9sJ1dF5gAe",
"socialAccounts": [
{
"accountId": "a1B2c3",
"network": "threads",
"username": "myaccount",
"platformPostId": "12345678901234567",
"platformPostUrl": "https://www.threads.net/@myaccount/post/DAbCdEfGhIj"
},
{
"accountId": "d4E5f6",
"network": "linkedin",
"username": "John Doe",
"platformPostId": "urn:li:share:7654321",
"platformPostUrl": "https://www.linkedin.com/feed/update/urn:li:share:7654321"
},
{
"accountId": "g7H8i9",
"network": "instagram",
"username": "myinsta",
"platformPostId": "17851234567890123",
"platformPostUrl": "https://www.instagram.com/p/DAbCdEfGhIj/"
}
]
}
}platformPostUrl is the public URL of the post on the social network.
post.error Event
Sent when a post fails to publish to all targeted social accounts.
{
"event": "post.error",
"timestamp": "2024-12-29T10:30:00.000Z",
"data": {
"postId": "9dyJS",
"orgId": "kR7mQ2xLpN4vT8wZ3bC6yH9sJ1dF5gAe",
"socialAccounts": [
{
"accountId": "a1B2c3",
"network": "threads",
"username": "myaccount",
"error": "Rate limit exceeded. Please try again later."
},
{
"accountId": "g7H8i9",
"network": "instagram",
"username": "myinsta",
"error": "Access token has expired"
}
]
}
}account.token_expired Event
Sent when a social account's OAuth token fails to refresh. This typically means the user needs to re-authenticate the account.
{
"event": "account.token_expired",
"timestamp": "2024-12-29T10:30:00.000Z",
"data": {
"orgId": "kR7mQ2xLpN4vT8wZ3bC6yH9sJ1dF5gAe",
"accountId": 42,
"network": "instagram",
"username": "myinsta",
"error": "Failed to refresh Instagram token: The access token is invalid"
}
}import.completed Event
Sent when a post import job finishes with all posts successfully imported.
{
"event": "import.completed",
"timestamp": "2024-12-29T10:30:00.000Z",
"data": {
"import_id": "9dyJS",
"status": "completed",
"imported": 42,
"skipped": 0,
"failed": 0
}
}import.failed Event
Sent when a post import job finishes with errors or only partially succeeds. The status field can be partial (some posts imported, some failed) or failed (nothing imported). The error field is only present when a specific error message is available.
{
"event": "import.failed",
"timestamp": "2024-12-29T10:30:00.000Z",
"data": {
"import_id": "9dyJS",
"status": "partial",
"imported": 30,
"skipped": 2,
"failed": 10,
"error": "Some posts could not be fetched from the platform"
}
}conversation.started Event
Sent the first time a contact messages one of your connected accounts, or when you start a conversation with them. It is delivered once per conversation, alongside the event for its first message: message.received, or message.sent when you started it.
{
"event": "conversation.started",
"timestamp": "2024-12-29T10:30:00.000Z",
"data": {
"conversationId": "9dyJS",
"orgId": "kR7mQ2xLpN4vT8wZ3bC6yH9sJ1dF5gAe",
"network": "instagram",
"participantId": "17841400000000000"
}
}message.received Event
Sent when a contact sends a direct message to one of your connected accounts. When the contact sent it by tapping a quick reply, data also carries interaction: { "type": "quick_reply", "id": "..." } with the id you gave that button.
{
"event": "message.received",
"timestamp": "2024-12-29T10:30:00.000Z",
"data": {
"conversationId": "9dyJS",
"messageId": "k2Lm8",
"orgId": "kR7mQ2xLpN4vT8wZ3bC6yH9sJ1dF5gAe",
"network": "instagram",
"content": "Hi, is this still in stock?",
"senderId": "17841400000000000",
"sentAt": "2024-12-29T10:29:58.000Z",
"sender": {
"isFollower": true,
"isFollowedByAccount": false,
"followerCount": 25,
"isVerified": false
}
}
}sender is what Instagram reveals about the contact, looked up when the message arrives, so it reflects a follow that happened since their last message:
| Field | Description |
|---|---|
isFollower | The contact follows your connected account |
isFollowedByAccount | Your connected account follows the contact |
followerCount | The contact's follower count |
isVerified | The contact has a verified Instagram account |
sender is null when the lookup fails, and a field inside it is null when Instagram does not return it. Treat null as unknown, never as "not a follower".
message.sent Event
Sent when a direct message goes out from one of your connected accounts. This covers messages sent through the API (source: "api") and messages the account owner sends from the Instagram app (source: "native_app"). App-sent messages are relayed but not stored, so their event has messageId: null and carries the message content; see Message lifecycle and webhooks.
{
"event": "message.sent",
"timestamp": "2024-12-29T10:30:00.000Z",
"data": {
"conversationId": "9dyJS",
"messageId": "p7Qx3",
"orgId": "kR7mQ2xLpN4vT8wZ3bC6yH9sJ1dF5gAe",
"network": "instagram",
"platformMessageId": "aWdfZAG1faXRlbToxOk...",
"source": "api"
}
}message.failed Event
Sent when a message submitted through the API could not be delivered. The error field carries the reason returned by the platform.
{
"event": "message.failed",
"timestamp": "2024-12-29T10:30:00.000Z",
"data": {
"conversationId": "9dyJS",
"messageId": "p7Qx3",
"orgId": "kR7mQ2xLpN4vT8wZ3bC6yH9sJ1dF5gAe",
"network": "instagram",
"error": "Instagram send failed: 403"
}
}comment.received Event
Sent when someone comments on, or replies to a comment on, a post owned by one of your connected accounts. This covers posts published outside Outstand too. Comments written by the connected account itself, including replies sent through the API, do not trigger it.
{
"event": "comment.received",
"timestamp": "2024-12-29T10:30:00.000Z",
"data": {
"orgId": "kR7mQ2xLpN4vT8wZ3bC6yH9sJ1dF5gAe",
"network": "instagram",
"accountId": "x8Kp2",
"postId": "a1B2c",
"platformPostId": "17912345678901234",
"commentId": "17865799348089039",
"parentCommentId": null,
"authorId": "17841400000000000",
"authorUsername": "jane.doe",
"text": "Love this! Is it available in blue?",
"author": null
}
}| Field | Description |
|---|---|
accountId | The connected social account that owns the post, as returned by GET /v1/social-accounts |
postId | The Outstand post the comment is on, or null when the post was not published through Outstand |
platformPostId | The post's ID on the network. On Threads, the first post of the thread the reply is in |
commentId | The comment's ID on the network. When postId is set, pass it as parent_comment_id to publish a reply under it. To answer the commenter in a direct message instead (Comment-to-DM), start a conversation with it as instagram.comment_id, authorId as instagram.author_id and accountId as social_account_id |
parentCommentId | The comment being replied to, or null for a top-level comment |
authorId, authorUsername | The commenter on the network. authorId is null on Threads, which only sends the username |
text | The comment text |
author | The commenter's follow relationship and follower count, in the same shape as sender on message.received. Instagram only reveals it to accounts the person has messaged, so it is null unless the commenter has sent your account a DM since it was connected, and always null on other networks. null means unknown, never "not a follower" |
Comments are not stored by Outstand, so this event is the only record of them we pass on. Use commentId to deduplicate: a comment can occasionally be delivered more than once.
Verifying Webhook Signatures
If you configure a signing secret, Outstand will include an X-Outstand-Signature header with each request. This allows you to verify that the webhook came from Outstand and wasn't tampered with.
The signature is computed as an HMAC-SHA256 hash of the raw request body using your signing secret:
// Node.js example
const crypto = require('crypto');
function verifySignature(payload, signature, secret) {
const expectedSignature = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(payload, 'utf8')
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}
// In your webhook handler
app.post('/webhook', (req, res) => {
const signature = req.headers['x-outstand-signature'];
const isValid = verifySignature(
JSON.stringify(req.body),
signature,
process.env.WEBHOOK_SECRET
);
if (!isValid) {
return res.status(401).send('Invalid signature');
}
// Process the webhook...
res.status(200).send('OK');
});# Python example
import hmac
import hashlib
def verify_signature(payload: str, signature: str, secret: str) -> bool:
expected = 'sha256=' + hmac.new(
secret.encode('utf-8'),
payload.encode('utf-8'),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)
# In your webhook handler
@app.route('/webhook', methods=['POST'])
def handle_webhook():
signature = request.headers.get('X-Outstand-Signature')
is_valid = verify_signature(
request.get_data(as_text=True),
signature,
os.environ['WEBHOOK_SECRET']
)
if not is_valid:
return 'Invalid signature', 401
# Process the webhook...
return 'OK', 200Delivery & Retries
Outstand provides a scalable and reliable delivery mechanism to deliver webhooks, which provides:
- Automatic retries: Failed deliveries are retried up to 5 times with exponential backoff
- Retry intervals: Starting at 10 seconds, increasing up to 5 minutes between attempts
- Timeout: Each delivery attempt has a 30-second timeout
Response Requirements
Your endpoint should:
- Return a
2xxstatus code (200-299) to acknowledge receipt - Respond within 30 seconds
- Be idempotent (handle duplicate deliveries gracefully)
If your endpoint returns a 4xx or 5xx status code, or times out, the delivery will be retried.
Best Practices
- Always verify signatures if you've configured a signing secret
- Respond quickly - do heavy processing asynchronously after acknowledging the webhook
- Handle duplicates - use the
postIdandtimestamp(orcommentIdforcomment.received) to deduplicate if needed - Use HTTPS - webhook endpoints must use HTTPS for security
- Test your endpoint - use the Test button in the UI before going live
- Monitor failures - set up alerting for failed webhook deliveries on your end
Testing Webhooks
You can test your webhook configuration directly from the Outstand UI:
- Go to Settings → Webhooks
- Find the webhook you want to test
- Click the Test button
This will send a test payload to your endpoint:
{
"event": "test",
"timestamp": "2024-12-29T10:30:00.000Z",
"data": {
"message": "This is a test webhook from Outstand",
"endpointId": 123
}
}The test result will show you the HTTP status code returned by your endpoint.