For Employers

Employer API

Send job applicants a speed test from your own systems, read their results, and get each new result as it arrives.

Base URLhttps://speedof.me/for-employers/api/v1

How It Works

The API works with the speed test links you make on your workspace page: each link is for one role, with the minimum download and upload speeds you need.

1

Invite

Send an applicant's email address to one of your links, with your own reference for them. We email the invitation, or hand the link back for you to send.

2

They test in their own browser

The applicant opens the link on the connection they will work on. The test takes about a minute, with nothing to install.

3

The result comes to you

It lands on your applicant list with your reference, in GET /applicants, and at your webhooks within minutes.

Getting a Key

  • The workspace owner creates keys on the workspace page, under API keys.
  • A key is shown once, when it is created: copy it then. Keys start with somhire_live_.
  • Keep it secret. A key sends invitations in your workspace's name and reads your applicants, so use it from your server, never in a web page or an app you hand out.
  • Revoking a key on the same page stops it at once and removes the webhooks it added.

Making Requests

Send every request to the base URL, https://speedof.me/for-employers/api/v1, with your key in the Authorization header as a Bearer token. Request bodies and answers are JSON, and times are UTC, in ISO 8601.

Request
curl https://speedof.me/for-employers/api/v1/me \
  -H "Authorization: Bearer $SPEEDOFME_KEY"

Routes

GET /me

Your workspace. A quick way to check that a key works.

Response 200
{
  "workspace": {
    "org": "Acme Support",
    "plan": "team",
    "livemode": true
  }
}
orgstring
The workspace's name.
planstring
free, team or business.
livemodeboolean
Always true.

POST /invites

Makes an applicant's own link to one of your speed test links and, unless you say otherwise, emails it to them.

Request body

linkstring, required
The slug of one of your speed test links. Case does not matter.
emailstring
The applicant's email address. Required unless send_email is false.
referencestring, optional
Your own id for the applicant, such as their candidate id in your applicant tracking system: 1 to 128 letters, digits or . _ : @ + = / - characters. A whole number is read as its digits. It comes back with their result.
send_emailboolean, optional
true, the default, to email the invitation. false to get the link without an email and send it yourself.
Request
curl https://speedof.me/for-employers/api/v1/invites \
  -H "Authorization: Bearer $SPEEDOFME_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "link": "support-agent-7kq4m",
    "email": "jane@example.com",
    "reference": "cand-123"
  }'
Response 201
{
  "url": "https://speedof.me/test/support-agent-7kq4m?...",
  "reference": "cand-123",
  "expires_at": "2026-12-12T20:00:00Z",
  "emailed": true
}
urlstring
The applicant's own link, longer than shown here. Send it as it is.
referencestring or null
Your reference, as it will come back.
expires_atstring
Until this time, a test started through this link carries your reference; after it, the link still opens the test, without your reference.
emailedboolean
Whether we emailed the invitation.

Errors

  • 422 invalid_request: a field is missing or not valid; the message names it.
  • 404 not_found: this workspace has no speed test link with that slug.
  • 409 link_paused: the link is paused. Resume it on the workspace page.
  • 429 invite_limit: no more invitation emails can be sent for now. Send the link yourself, or try again later.
  • 502 mail_failed: the email could not be sent. Try again in a few minutes.

GET /applicants

Your latest applicants, newest first, in the shape of the applicant.

Query parameters

referencestring, optional
Only results with this reference, matched exactly, case included.
linkstring, optional
Only results through the speed test link with this slug.
Request
curl "https://speedof.me/for-employers/api/v1/applicants?reference=cand-123" \
  -H "Authorization: Bearer $SPEEDOFME_KEY"
Response 200
{
  "data": [
    {
      "id": 4567,
      "reference": "cand-123",
      "submitted_at": "2026-11-12T20:30:00Z",
      "link": {
        "slug": "support-agent-7kq4m",
        "role": "Support Agent",
        "min_download_mbps": 25,
        "min_upload_mbps": 5
      },
      "name": "Jane Doe",
      "result": "meets",
      "download_mbps": 85.2,
      "upload_mbps": 12.1,
      "latency_ms": 23,
      "provider": "PLDT",
      "connection": "Home internet",
      "city": "Quezon City",
      "country": "PH",
      "phone_last4": "0614",
      "phone_confirmed": true,
      "certificate_url": "https://speedof.me/v/7KQ4-MR2D"
    }
  ]
}

Errors

  • 422 invalid_request: the reference is not 1 to 128 of the characters a reference allows.
  • 404 not_found: this workspace has no speed test link with that slug.

POST /hooks

Adds a webhook: from then on, each new applicant is sent to its address (see Webhooks).

Request body

urlstring, required
Your endpoint's full https address.
Request
curl https://speedof.me/for-employers/api/v1/hooks \
  -H "Authorization: Bearer $SPEEDOFME_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/speedofme"}'
Response 201
{
  "id": 12,
  "url": "https://example.com/speedofme",
  "secret": "whsec_...",
  "created_at": "2026-11-12T20:00:00Z"
}
idinteger
The webhook's id, to remove it later.
urlstring
The address, as you sent it.
secretstring
What every delivery to this webhook is signed with. Keep it with the address; the workspace owner can read it again on the workspace page, under Webhooks.
created_atstring
When it was added. Applicants who arrive from then on are sent to it.

Errors

  • 422 invalid_request: the webhook cannot be added; the message says why.

DELETE /hooks/{id}

Removes a webhook: nothing more is sent to it.

Request
curl -X DELETE https://speedof.me/for-employers/api/v1/hooks/12 \
  -H "Authorization: Bearer $SPEEDOFME_KEY"
Response 200
{
  "deleted": true
}

Errors

  • 404 not_found: this workspace has no webhook with that id.

The Applicant

GET /applicants lists applicants, and each webhook carries one, in this shape. It holds what your applicant list shows, rounded as it shows it, and never the applicant's email address or full phone number.

idinteger
The result's id.
referencestring or null
Your reference from the invitation the applicant tested through, or null.
submitted_atstring
When the applicant sent you their result.
linkobject
The speed test link they used: its slug, role, min_download_mbps and min_upload_mbps, as in GET /links.
namestring or null
The name the applicant entered.
resultstring
meets or below: whether the download and upload speeds meet the link's minimums, compared as they are shown here. A minimum that is not set is always met. It looks at the speeds only; connection says what kind of line they tested on.
download_mbpsnumber or null
Download speed in Mbps, as the list and the certificate show it: one decimal below 100, whole numbers from 100.
upload_mbpsnumber or null
Upload speed, shown the same way.
latency_msinteger or null
Latency in milliseconds.
providerstring or null
The connection's internet provider.
connectionstring
The kind of line: Home internet, Business network, VPN or data center, Mobile network or Connection type unknown.
citystring or null
The approximate city of the connection.
countrystring or null
Its two-letter country code, such as PH.
phone_last4string or null
The last 4 digits of the phone number the applicant gave.
phone_confirmedboolean
Whether they confirmed that phone with a one-time code.
certificate_urlstring or null
Where the certificate the applicant bought can be checked, or null when there is none.

Webhooks

A webhook sends each new applicant to your own system: everyone who arrives on your list after you add it, shortly after they submit. Applicants from before it was added are not sent: read them with GET /applicants.

The request

A POST of this JSON to the webhook's address:

Body
{
  "id": "evt_8812",
  "type": "applicant.submitted",
  "created": "2026-11-12T20:33:00Z",
  "livemode": true,
  "data": {
    "id": 4567,
    "reference": "cand-123",
    "submitted_at": "2026-11-12T20:30:00Z",
    "link": {
      "slug": "support-agent-7kq4m",
      "role": "Support Agent",
      "min_download_mbps": 25,
      "min_upload_mbps": 5
    },
    "name": "Jane Doe",
    "result": "meets",
    "download_mbps": 85.2,
    "upload_mbps": 12.1,
    "latency_ms": 23,
    "provider": "PLDT",
    "connection": "Home internet",
    "city": "Quezon City",
    "country": "PH",
    "phone_last4": "0614",
    "phone_confirmed": true,
    "certificate_url": "https://speedof.me/v/7KQ4-MR2D"
  }
}
idstring
The event's id, the same on every try.
typestring
applicant.submitted, the only type.
createdstring
When the event was made.
livemodeboolean
Always true.
dataobject
The applicant, as they are when it is sent.

Headers

SOM-Event-Id
The event's id, as in the body.
SOM-Signature
t=1794515580,v1= and 64 hex characters: the Unix time of this try, and the signature.

Checking the signature

v1 is the hex HMAC-SHA256 of t, a full stop and the raw body, keyed with the webhook's secret: the whole whsec_ string, as text. Compute it over the body exactly as it arrived, before parsing the JSON, compare in constant time, and refuse a stale t, as the examples below do: every try is signed afresh.

Node.js
const crypto = require('crypto');

// body: the raw request body, exactly as it arrived (a Buffer or a string)
// header: the SOM-Signature header; secret: the webhook's whsec_ secret
function isFromSpeedOfMe(body, header, secret) {
  const sig = Object.fromEntries(String(header || '').split(',').map((p) => p.split('=', 2)));
  const want = crypto.createHmac('sha256', secret).update(sig.t + '.').update(body).digest('hex');
  const got = Buffer.from(String(sig.v1 || ''));
  return got.length === want.length
    && crypto.timingSafeEqual(got, Buffer.from(want))
    && Math.abs(Date.now() / 1000 - Number(sig.t)) < 300;
}

// Express: app.post('/speedofme', express.raw({ type: 'application/json' }), ...)
// keeps req.body raw; check it, then JSON.parse(req.body).
Python
import hashlib
import hmac
import time

# body: the raw request body, exactly as it arrived (bytes)
# header: the SOM-Signature header; secret: the webhook's whsec_ secret
def is_from_speedofme(body: bytes, header: str, secret: str) -> bool:
    sig = dict(p.split("=", 1) for p in (header or "").split(",") if "=" in p)
    t = sig.get("t", "")
    try:
        age = abs(time.time() - int(t))
    except ValueError:
        return False
    if age > 300:
        return False
    want = hmac.new(secret.encode(), t.encode() + b"." + body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(want.encode(), sig.get("v1", "").encode())

# Flask: is_from_speedofme(request.get_data(), request.headers.get("SOM-Signature"), secret)

To check your code: the secret whsec_zTqObEOJ_mN4EpvbaVePjUJsfRGu8IdTUuoEO9FTvfE, t 1700000000 and the body {"id":"evt_1"} give v1 83a2c92d878a7276bdebbe70487e5d7e51fd1bdafb5f6d22e1ae11b6342eaf78.

Answering

  • Answer with any 2xx, quickly. Do slow work after answering; what you send back is ignored.
  • Any other answer but 410, or none, is tried again later.
  • A delivery can arrive more than once. The event id stays the same on every try, so drop one you have already handled.
  • Answer 410 to remove the webhook. Nothing more is sent to it.

Errors

An error answers with its HTTP status and this body. The code is for your program; the message is one sentence for a person, so match on the code.

Response 422
{
  "error": {
    "code": "invalid_request",
    "message": "email must be a valid email address when send_email is true."
  }
}
CodeHTTPWhen
missing_key401No key was sent. Put it in the Authorization header, as Bearer and then the key.
invalid_key401The key is not one we know: incomplete, mistyped or revoked.
not_found404Nothing is at that address, or this workspace has no speed test link or webhook with that slug or id.
method_not_allowed405The route takes another method; the Allow header names it.
link_paused409The speed test link is paused. Resume it on the workspace page.
invalid_request422The request is not valid: the body is not a JSON object, or a field is missing or wrong. The message says which.
rate_limited429Too many requests with this key. Wait the seconds in Retry-After, then try again.
invite_limit429No more invitation emails can be sent for now. Send the link yourself, or try again later.
mail_failed502The invitation email could not be sent. Try again in a few minutes.
unavailable503The API is unavailable just now. Try again in a few minutes.

Start in Your Workspace

Make a speed test link and a key on the workspace page, then send your first invitation. Questions about the API: support@speedof.me.