Ping

Callbacks & Polling

When using interactive actions, you can choose how to receive the user's response:

  1. Callback URL - Ping POSTs the response to your server
  2. 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

JSON
{
  "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
}
FieldDescription
eventresponse.received for a normal interactive response. Messages on a channel with a multi-signature approval rule deliver approval.resolved instead (payload below)
message_idThe message ID that was interacted with
channel_idThe channel the message belongs to
timestampISO8601 timestamp of when the action was triggered
triggered_byThe key of the button/action that was tapped
responsesObject containing all form field values (keyed by field key)
metadataReserved. 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:

JSON
{
  "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
}
FieldDescription
statusapproved, rejected, or expired (timeout; also rejected if any rejection was recorded before the deadline)
approvals_received / rejections_received / required_countVote counts at resolution and the quorum that was required
votesEvery 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

Terminal
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 now
Alice requested access to production

Option 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

Terminal
# 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 now
Deploy v2.1.0 to production

Poll for the Response

Terminal
# 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

StatusMeaning
pendingUser hasn't responded yet
submittedUser responded - check result field
expiredResponse expired (10-minute TTL)
already_readResponse was already consumed
failedCallback 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

JSON
{
  "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

ProtectionDescription
Single readResponses can only be read once, then they're cleared
Auto-expireResponses expire after 10 minutes if not retrieved
Token authUses your webhook token (no session needed)
No overwriteCannot submit multiple responses to the same message

Testing Callbacks

Use the callback test server in the Ping repository to test callbacks locally:

Terminal
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 POST

Preview

Callback Test

just now