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.
https://speedof.me/for-employers/api/v1How 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.
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.
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.
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.
curl https://speedof.me/for-employers/api/v1/me \
-H "Authorization: Bearer $SPEEDOFME_KEY"
Routes
- GET
/meYour workspace - GET
/linksYour speed test links - POST
/invitesInvite an applicant - GET
/applicantsYour latest applicants - POST
/hooksAdd a webhook - DELETE
/hooks/{id}Remove a webhook
GET /me
Your workspace. A quick way to check that a key works.
{
"workspace": {
"org": "Acme Support",
"plan": "team",
"livemode": true
}
}
orgstring- The workspace's name.
planstringfree,teamorbusiness.livemodeboolean- Always
true.
GET /links
Your speed test links, newest first, paused ones included.
{
"data": [
{
"slug": "support-agent-7kq4m",
"url": "https://speedof.me/test/support-agent-7kq4m",
"role": "Support Agent",
"min_download_mbps": 25,
"min_upload_mbps": 5,
"active": true
}
]
}
slugstring- The link's id: give it to
POST /invites, and use it to narrowGET /applicants. urlstring- The link itself, for a job post or an email of your own.
rolestring- The job the link is for.
min_download_mbpsnumber or null- The minimum download speed a result is compared with, or
nullfor none. min_upload_mbpsnumber or null- The same for upload.
activebooleanfalsewhile the link is paused.
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_emailisfalse. 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, optionaltrue, the default, to email the invitation.falseto get the link without an email and send it yourself.
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"
}'
{
"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.
curl "https://speedof.me/for-employers/api/v1/applicants?reference=cand-123" \
-H "Authorization: Bearer $SPEEDOFME_KEY"
{
"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
httpsaddress.
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"}'
{
"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.
curl -X DELETE https://speedof.me/for-employers/api/v1/hooks/12 \
-H "Authorization: Bearer $SPEEDOFME_KEY"
{
"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_mbpsandmin_upload_mbps, as inGET /links. namestring or null- The name the applicant entered.
resultstringmeetsorbelow: 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;connectionsays 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 networkorConnection 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
nullwhen 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:
{
"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.
typestringapplicant.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-Signaturet=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.
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).
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
410to 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.
{
"error": {
"code": "invalid_request",
"message": "email must be a valid email address when send_email is true."
}
}
| Code | HTTP | When |
|---|---|---|
missing_key | 401 | No key was sent. Put it in the Authorization header, as Bearer and then the key. |
invalid_key | 401 | The key is not one we know: incomplete, mistyped or revoked. |
not_found | 404 | Nothing is at that address, or this workspace has no speed test link or webhook with that slug or id. |
method_not_allowed | 405 | The route takes another method; the Allow header names it. |
link_paused | 409 | The speed test link is paused. Resume it on the workspace page. |
invalid_request | 422 | The request is not valid: the body is not a JSON object, or a field is missing or wrong. The message says which. |
rate_limited | 429 | Too many requests with this key. Wait the seconds in Retry-After, then try again. |
invite_limit | 429 | No more invitation emails can be sent for now. Send the link yourself, or try again later. |
mail_failed | 502 | The invitation email could not be sent. Try again in a few minutes. |
unavailable | 503 | The 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.