Callbacks & Polling
When using interactive actions, you can choose how to receive the user's response:
- Callback URL - Ping POSTs the response to your server
- Polling - Poll Ping for the response (no external server needed)
Option 1: Callback URL
When you provide a callback_url, Ping POSTs the response to your server once the user
interacts with an action. The delivery comes from Ping's servers — not from the device —
so it is HMAC-signed and retried with exponential backoff (about 1 min, 2 min, 4 min ... capped at 1 h) for up to 8 attempts. After the 8th non-2xx the delivery is dead-lettered and the message's callback status becomes failed; Ping does not retry further, even if your endpoint recovers later.
Callback Request Format
{
"event": "response.received",
"message_id": "msg_xxxxxxxxxxxx",
"channel_id": "ch_xxxxxxxxxxxx",
"timestamp": "2026-01-12T20:18:13Z",
"triggered_by": "approve",
"responses": {
"rating": "good",
"comment": "User entered text"
},
"metadata": null
}| Field | Description |
|---|---|
event | response.received for a normal interactive response. Messages on a channel with a multi-signature approval rule deliver approval.resolved instead (payload below) |
message_id | The message ID that was interacted with |
channel_id | The channel the message belongs to |
timestamp | ISO8601 timestamp of when the action was triggered |
triggered_by | The key of the button/action that was tapped |
responses | Object containing all form field values (keyed by field key) |
metadata | Reserved. POST /v1/send/:token does not accept a metadata field yet, so this is always null today |
Multi-signature approvals: no callback is delivered until the approval reaches quorum. A single approver tapping the notification does not trigger delivery.
Multi-signature resolution payload (approval.resolved)
On a channel with an approval rule the only callback you receive is the resolution, sent once the quorum approves, any voter rejects, or the approval window times out:
{
"event": "approval.resolved",
"message_id": "msg_xxxxxxxxxxxx",
"channel_id": "ch_xxxxxxxxxxxx",
"status": "approved",
"approvals_received": 2,
"rejections_received": 0,
"required_count": 2,
"votes": [
{ "user_id": "user_xxx", "user_name": "Alice", "action": "approve", "response_data": {}, "responded_at": "2026-01-12T20:18:13Z" }
],
"timestamp": "2026-01-12T20:18:20Z",
"metadata": null
}| Field | Description |
|---|---|
status | approved, rejected, or expired (timeout; also rejected if any rejection was recorded before the deadline) |
approvals_received / rejections_received / required_count | Vote counts at resolution and the quorum that was required |
votes | Every recorded vote, oldest first |
There is no triggered_by on this event: branch on status. Delivery is signed and
retried like response.received (up to 8 attempts), so it can arrive more than once; deduplicate on
message_id + event.
Example: Approval Workflow
curl -X POST 'YOUR_WEBHOOK_URL' \
-H 'Content-Type: application/json' \
-d '{
"title": "Approve Request",
"body": "Alice requested access to production",
"callback_url": "https://your-server.com/approvals",
"actions": [
{"type": "button", "label": "Approve", "key": "approve", "style": "primary"},
{"type": "button", "label": "Reject", "key": "reject", "style": "destructive"}
]
}'Preview
Approve Request
just nowOption 2: Polling (No Server Required)
For simple approval workflows, you can skip the callback_url entirely. When the user taps a button or submits a form, their response is stored in Ping. Your system can then poll for the response using your webhook token.
This is the "Ping it Back" pattern - send a notification, get a response, all without running a server.
Ideal for:
- CLI tools waiting for user approval
- AI agents (like Claude Code) that need human confirmation
- CI/CD pipelines needing deployment approval
- n8n/Zapier workflows with human-in-the-loop decisions
- Any script that needs mobile user input
Send the Notification
# No callback_url needed - we'll poll for the response
RESPONSE=$(curl -s -X POST 'https://ping-api-production.up.railway.app/v1/send/YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-d '{
"title": "Approve Deploy?",
"body": "Deploy v2.1.0 to production",
"actions": [
{"type": "button", "label": "Approve", "key": "approve", "style": "primary"},
{"type": "button", "label": "Reject", "key": "reject", "style": "destructive"}
]
}')
# Extract message ID
MSG_ID=$(echo $RESPONSE | jq -r '.id')
echo "Waiting for response on message: $MSG_ID"Preview
Approve Deploy?
just nowPoll for the Response
# Uses webhook token, no auth header needed
curl -s "https://ping-api-production.up.railway.app/v1/callback/YOUR_TOKEN/$MSG_ID"
# Before user responds:
# {"status": "pending"}
# After user taps "Approve":
# {"status": "submitted", "result": {"triggered_by": "approve", "responses": {}}, "submitted_at": "..."}Polling Response Statuses
| Status | Meaning |
|---|---|
pending | User hasn't responded yet |
submitted | User responded - check result field |
expired | Response expired (10-minute TTL) |
already_read | Response was already consumed |
failed | Callback delivery failed |
On a channel with a multi-signature approval rule the same statuses apply: an approved or rejected quorum arrives as submitted with result.triggered_by set to approve or reject (plus an approval_status field), and a timed-out approval arrives as expired.
Full Polling Response
{
"status": "submitted",
"result": {
"message_id": "msg_xxxxxxxxxxxx",
"channel_id": "ch_xxxxxxxxxxxx",
"timestamp": "2026-01-13T15:30:00Z",
"triggered_by": "approve",
"responses": {
"environment": "production",
"notes": "Looks good!"
}
},
"submitted_at": "2026-01-13T15:30:00Z"
}Security Protections
| Protection | Description |
|---|---|
| Single read | Responses can only be read once, then they're cleared |
| Auto-expire | Responses expire after 10 minutes if not retrieved |
| Token auth | Uses your webhook token (no session needed) |
| No overwrite | Cannot submit multiple responses to the same message |
Testing Callbacks
Use the callback test server in the Ping repository to test callbacks locally:
cd ping-api
# 1. Start the callback test server
node tests/callback-server.cjs 3001
# 2. In another terminal, start ngrok
ngrok http 3001
# 3. Copy the ngrok URL (e.g., https://abc123.ngrok-free.app)
# 4. Send a test notification with that callback URL
curl -X POST https://ping-api-production.up.railway.app/v1/send/YOUR_TOKEN \
-H "Content-Type: application/json" \
-d '{
"title": "Callback Test",
"callback_url": "https://abc123.ngrok-free.app",
"actions": [
{"type": "button", "label": "Approve", "key": "approve", "style": "primary"}
]
}'
# 5. Open the message in iOS app detail view and tap the button
# 6. Watch the callback server terminal for the incoming POSTPreview
Callback Test
just nowRelated
- Interactive Actions - Action types and options
- Webhook Signatures - Verify callback authenticity
- Ping it Back - Complete examples