Skip to content

API reference/phone

Report SMS outcomes

POST/api/v1/phone/feedback

authentication
Secret API key as a bearer token
billing
Free.

Tells Spaw what happened after you sent an SMS to a number: delivered, undelivered (the carrier reported it could not be delivered), abused (the code was used by someone who was not the recipient, or the number was involved in fraud), verified (the recipient entered the code the SMS carried) or opted_out (the recipient unsubscribed, for instance by replying STOP). Your own reports feed your own later lookups of that number as risk signals with the dataset delivery-feedback: a number whose latest report within 90 days is a failure about the number scores reported_undelivered (+50), a number with any abuse report within 90 days scores reported_abuse (+60), and a delivery or a verification reported after a failure cancels that failure. No other account ever sees your reports.

verified counts as a delivery everywhere a delivery counts: it cancels an earlier failure and clears a suppression entry your failure reports added. An abuse report is not a failure: the entry it adds, reason abuse, stays until you remove it — no delivery, verification, lookup or age takes it off, even once the report has left the 90 days it scores for. Only an abused item in the request that carries it adds or relabels an abuse entry, so an abuse entry you removed does not come back from a later delivered, verified or undelivered report while the old report is still in its 90 days; only the next abuse report restores it (an opt-out, or failures that would suppress any number, still add it with reason opted_out or undelivered). On a number you imported, an abuse report is recorded on the import, which keeps source imported. Report it beside your delivery receipts and the summary's verified_share reads as your OTP conversion — traffic pumping is delivered and never verified.

opted_out is never a risk signal: it says nothing about whether the number works. It puts the number on your suppression list with reason opted_out — recorded on the entry you imported, if you did, which keeps source imported — where batch, bulk and monitor runs serve it suppressed. A single lookup answers and scores it in full with meta.opted_out: true and, under the default SMS policy, ok_to_send: false with blocked_by: opted_out; an account that sends one-time codes may switch block_opted_out off (PUT /api/v1/phone/policy). Nothing but removing the entry takes it off — not a later delivery, a verification, a lookup or age — and removing it stands until the next opt-out is reported. It is your own record: honouring an opt-out stays your obligation, and Spaw is not a consent record.

An undelivered item may name its failure_class, read from your SMS provider's error code: permanent (the number no longer exists or is barred for good), transient (the handset was off, out of coverage or busy), not_sms_capable (a landline or another number that cannot take SMS) or sender_side (a carrier filter, an unregistered campaign or sender, a rate limit — your side of the send, not the number). Only failures about the number count: permanent, transient and failures sent without a class feed reported_undelivered and your suppression list, where one permanent failure suppresses the number at once and the others need two with no delivery after them. not_sms_capable and sender_side failures are kept and counted in the summary, never scored. occurred_at may not be more than five minutes in the future; a time inside that margin is recorded as now.

Numbers are normalised like a lookup; national notation needs a country on the item or on the request. Items whose number does not parse as a valid subscriber number are skipped and counted, not rejected. Feedback is free and never logged as a lookup. Outcomes are kept for 180 days.

Request body

field type description
itemsrequiredobject[]1 to 1,000 outcomes per request.
items[].phonerequiredstringThe number, in any common notation. At most 64 characters.
items[].outcomerequiredstringverified: the recipient entered the code, which counts as a delivery. opted_out: the recipient unsubscribed; never scored, and it suppresses the number with reason opted_out until you remove the entry. One of: delivered, undelivered, abused, verified, opted_out.
items[].failure_classstring | nullFor an undelivered outcome only: what kind of failure it was. permanent (the number no longer exists or is barred for good) and transient (the handset was off, out of coverage or busy) count against the number, like a failure with no class; not_sms_capable and sender_side are recorded and summarised, never scored. A class on any other outcome is a 422; an opt-out is its own outcome, not a class. One of: permanent, transient, not_sms_capable, sender_side, null.
items[].countrystring | nullAn ISO 3166-1 alpha-2 region for a number written without a calling code; overrides the request-level country.
items[].occurred_atstring | nullWhen it happened. Defaults to now. A time more than five minutes in the future is refused; one inside that margin is recorded as now.
countrystring | nullAn ISO 3166-1 alpha-2 region applied to items that do not name their own, for numbers written without a calling code.

Example request

curl -X POST https://spaw.co/api/v1/phone/feedback \
  -H "Authorization: Bearer sk_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "items": [
    {
      "phone": "+44 7911 012345",
      "outcome": "undelivered",
      "failure_class": "transient"
    },
    {
      "phone": "(415) 555-0142",
      "country": "US",
      "outcome": "delivered",
      "occurred_at": "2026-09-05T09:00:00Z"
    },
    {
      "phone": "(415) 555-0142",
      "country": "US",
      "outcome": "verified",
      "occurred_at": "2026-09-05T09:01:10Z"
    }
  ]
}'
const response = await fetch('https://spaw.co/api/v1/phone/feedback', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer sk_live_…',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    "items": [
      {
        "phone": "+44 7911 012345",
        "outcome": "undelivered",
        "failure_class": "transient"
      },
      {
        "phone": "(415) 555-0142",
        "country": "US",
        "outcome": "delivered",
        "occurred_at": "2026-09-05T09:00:00Z"
      },
      {
        "phone": "(415) 555-0142",
        "country": "US",
        "outcome": "verified",
        "occurred_at": "2026-09-05T09:01:10Z"
      }
    ]
  }),
});
const result = await response.json();
import requests

response = requests.post(
    'https://spaw.co/api/v1/phone/feedback',
    headers={'Authorization': 'Bearer sk_live_…'},
    json={
        'items': [
            {
                'phone': '+44 7911 012345',
                'outcome': 'undelivered',
                'failure_class': 'transient'
            },
            {
                'phone': '(415) 555-0142',
                'country': 'US',
                'outcome': 'delivered',
                'occurred_at': '2026-09-05T09:00:00Z'
            },
            {
                'phone': '(415) 555-0142',
                'country': 'US',
                'outcome': 'verified',
                'occurred_at': '2026-09-05T09:01:10Z'
            }
        ]
    },
)
result = response.json()
$ch = curl_init('https://spaw.co/api/v1/phone/feedback');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…', 'Content-Type: application/json'],
    CURLOPT_POSTFIELDS => json_encode([
        'items' => [
            [
                'phone' => '+44 7911 012345',
                'outcome' => 'undelivered',
                'failure_class' => 'transient'
            ],
            [
                'phone' => '(415) 555-0142',
                'country' => 'US',
                'outcome' => 'delivered',
                'occurred_at' => '2026-09-05T09:00:00Z'
            ],
            [
                'phone' => '(415) 555-0142',
                'country' => 'US',
                'outcome' => 'verified',
                'occurred_at' => '2026-09-05T09:01:10Z'
            ]
        ]
    ]),
]);
$result = json_decode(curl_exec($ch), true);
from spaw import Client

client = Client('sk_live_…')
result = client.report_phone_feedback([
    {
        'phone': '+44 7911 012345',
        'outcome': 'undelivered',
        'failure_class': 'transient'
    },
    {
        'phone': '(415) 555-0142',
        'country': 'US',
        'outcome': 'delivered',
        'occurred_at': '2026-09-05T09:00:00Z'
    },
    {
        'phone': '(415) 555-0142',
        'country': 'US',
        'outcome': 'verified',
        'occurred_at': '2026-09-05T09:01:10Z'
    }
])
import Spaw from 'spaw';

const spaw = new Spaw({ apiKey: 'sk_live_…' });
const result = await spaw.reportPhoneFeedback([
    {
        phone: '+44 7911 012345',
        outcome: 'undelivered',
        failure_class: 'transient'
    },
    {
        phone: '(415) 555-0142',
        country: 'US',
        outcome: 'delivered',
        occurred_at: '2026-09-05T09:00:00Z'
    },
    {
        phone: '(415) 555-0142',
        country: 'US',
        outcome: 'verified',
        occurred_at: '2026-09-05T09:01:10Z'
    }
]);
use Spaw\Client;

$spaw = new Client('sk_live_…');
$result = $spaw->reportPhoneFeedback([
    [
        'phone' => '+44 7911 012345',
        'outcome' => 'undelivered',
        'failure_class' => 'transient'
    ],
    [
        'phone' => '(415) 555-0142',
        'country' => 'US',
        'outcome' => 'delivered',
        'occurred_at' => '2026-09-05T09:00:00Z'
    ],
    [
        'phone' => '(415) 555-0142',
        'country' => 'US',
        'outcome' => 'verified',
        'occurred_at' => '2026-09-05T09:01:10Z'
    ]
]);

This endpoint has no console on its page. It writes to your account. A documentation page can show you the request; making the change is for the dashboard or for a call you make yourself.

Responses

202The outcomes were recorded.

{
    "success": true,
    "data": {
        "recorded": 2,
        "skipped": 0
    }
}

401The key is missing, malformed, or revoked.

{
    "success": false,
    "error": {
        "code": "UNAUTHENTICATED",
        "message": "Provide a valid API key as a bearer token.",
        "request_id": "req_01m1kgdm4xngzmbmff68g94w0c"
    }
}

422The request body could not be validated; `error.errors` lists the fields.

{
    "success": false,
    "error": {
        "code": "VALIDATION_FAILED",
        "message": "The email field is required.",
        "errors": {
            "email": [
                "The email field is required."
            ]
        },
        "request_id": "req_01m1kgdm4xngzmbmff68g94w0c"
    }
}

429Over 5 requests per second for the key. Retry after the limit resets.

{
    "success": false,
    "error": {
        "code": "RATE_LIMITED",
        "message": "Too many requests. Retry after the limit resets.",
        "request_id": "req_01m1kgdm4xngzmbmff68g94w0c"
    }
}

Error codes

Failures answer { success: false, error: { code, message, request_id } }. Each code has its own page.

markdown version·openapi.json