API reference
Get from your first API key to a working integration. Validate Kenyan phone numbers, resolve M-Pesa hashes, or decode a CSV.
https://api.dehashit.com/api/v1/Make your first request
Start with phone validation. It’s free and uses the same authentication as billable lookups.
- Create an API key. Open the API keys dashboard and copy your secret. Store it on your server.
- Send a validation request. Choose your language, replace the example key, and run the sample.
- Read the result. A successful response has
status: trueand adataobject. See a validation response.
No API key yet? Try the lookup tool to explore phone-number hashing.
Choose an endpoint
/api/v1/validations/phone-validation/Always freeLook up hashed MSISDNFind the phone number behind a supported M-Pesa hash.POST/api/v1/msisdn/decrypt/KES 0.10 per matchBatch decodeResolve up to 5,000 hashes from a CSV upload.POST/api/v1/batch/Billed per matched rowIntegration reference
Authentication, errors, and limits, followed by the complete endpoint reference.
Authentication
Include your API key in the X-Api-Key HTTP header when calling the API from your server. Never expose keys in client-side code, mobile applications, or public version control.
Authentication header
X-Api-Key: dh_live_...Replace dh_live_... with your own key. The example below validates a phone number and is always free, so you can confirm authentication before making a billable lookup.
Example authenticated request
curl -X POST https://api.dehashit.com/api/v1/validations/phone-validation/ \
-H "X-Api-Key: dh_live_..." \
-H "Content-Type: application/json" \
-d '{"phone_number": "254712345678", "country_code": "254"}'import requests
res = requests.post(
"https://api.dehashit.com/api/v1/validations/phone-validation/",
headers={
"X-Api-Key": "dh_live_...",
"Content-Type": "application/json",
},
json={"phone_number": "254712345678", "country_code": "254"},
timeout=10,
)
print(res.json())const res = await fetch("https://api.dehashit.com/api/v1/validations/phone-validation/", {
method: "POST",
headers: {
"X-Api-Key": "dh_live_...",
"Content-Type": "application/json",
},
body: JSON.stringify({ phone_number: "254712345678", country_code: "254" }),
});
const data = await res.json();
console.log(data);package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
)
func main() {
body, _ := json.Marshal(map[string]string{
"phone_number": "254712345678",
"country_code": "254",
})
req, _ := http.NewRequest("POST", "https://api.dehashit.com/api/v1/validations/phone-validation/", bytes.NewReader(body))
req.Header.Set("X-Api-Key", "dh_live_...")
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
fmt.Println(res.Status)
}Manage your keys
Generate, rotate, and revoke keys in the API keys dashboard. Copy a new key when it is created: its secret is shown only once. Rotate a key to replace it, and revoke any key you no longer use. Requests with missing, invalid, or revoked keys return 401 unauthorized. Dashboard web sessions use an HTTP-only cookie. Use API keys for these server integrations; dashboard sessions do not authenticate the decrypt or validation endpoints.
Errors
Successful responses return {"status": true, "status_code": 0, "message": "...", "data": {...}}. Failed responses return status: false, set the HTTP response code to match status_code, and usually provide a machine-readable error_code. Branch on error_code, not on message. Use the HTTP status as a fallback when no error code is provided.
Error codes and recovery
| HTTP | error_code | Meaning and next step |
|---|---|---|
| 400 | invalid_request | Request payload failed validation. Inspect errors for field details. |
| 401 | unauthorized | Missing, invalid, or revoked API key. |
| 402 | insufficient_balance | Match found, but wallet balance is below the lookup fee. Number withheld. |
| 403 | forbidden | Browser origin or CSRF checks failed, permissions are insufficient, or a service key attempted batch access. |
| 404 | not_found | No matching hash, unsupported country, missing resource, or expired batch results. Do not retry unchanged. |
| 405 | — | HTTP method is not supported. This response has no error_code; check the endpoint method. |
| 409 | idempotency_key_in_progress | Batch is still processing or its results are not ready. Wait before checking the same job again. |
| 413 | payload_too_large | Request body exceeds 8 KiB, or 2 MiB for a batch upload. Reduce its size. |
| 422 | idempotency_key_reused | The retry key was used with another hash or different batch bytes. Use a new key for a new lookup or upload. |
| 429 | rate_limited | Rate limit exceeded. Wait for Retry-After seconds before retrying. |
| 502 | provider_failed | M-Pesa gateway call failed (top-ups only). |
| 503 | dataset_unavailable | Lookup is temporarily unavailable. Retry later; never billed. |
| 503 | dataset_format_legacy | Lookup is paused for maintenance. Retry later or contact support; never billed. |
| 503 | lookup_timeout | Lookup timed out. Retry; never billed. |
| 503 | unavailable | Billing or database unavailable. Never billed. |
Error handling example
curl -i -X POST https://api.dehashit.com/api/v1/msisdn/decrypt/ \
-H "X-Api-Key: invalid_key" \
-H "Content-Type: application/json" \
-d '{"msisdn_hash": "7132104d6aae9c3fac82095a42c2817952bca48e09d98d5bf4ac08218982fb90"}'import requests
res = requests.post(
"https://api.dehashit.com/api/v1/msisdn/decrypt/",
headers={"X-Api-Key": "dh_live_..."},
json={"msisdn_hash": "7132104d6aae9c3fac82095a42c2817952bca48e09d98d5bf4ac08218982fb90"},
timeout=10,
)
payload = res.json()
if not payload.get("status"):
error_code = payload.get("error_code")
if error_code == "insufficient_balance":
print("Please top up your wallet.")
elif error_code == "not_found":
print("No match for this hash.")
else:
print(f"Error {res.status_code}: {payload.get('message')}")const res = await fetch("https://api.dehashit.com/api/v1/msisdn/decrypt/", {
method: "POST",
headers: { "X-Api-Key": "dh_live_...", "Content-Type": "application/json" },
body: JSON.stringify({ msisdn_hash: "7132104d6aae9c3fac82095a42c2817952bca48e09d98d5bf4ac08218982fb90" }),
});
const body = await res.json();
if (!body.status) {
switch (body.error_code) {
case "insufficient_balance":
console.error("Top up required.");
break;
case "not_found":
console.warn("Hash not found.");
break;
default:
console.error(`Error (${res.status}): ${body.message}`);
}
}package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
)
type APIEnvelope struct {
Status bool `json:"status"`
StatusCode int `json:"status_code"`
ErrorCode string `json:"error_code,omitempty"`
Message string `json:"message"`
}
func main() {
body, _ := json.Marshal(map[string]string{
"msisdn_hash": "7132104d6aae9c3fac82095a42c2817952bca48e09d98d5bf4ac08218982fb90",
})
req, _ := http.NewRequest("POST", "https://api.dehashit.com/api/v1/msisdn/decrypt/", bytes.NewReader(body))
req.Header.Set("X-Api-Key", "dh_live_...")
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
var envelope APIEnvelope
if err := json.NewDecoder(res.Body).Decode(&envelope); err != nil {
panic(err)
}
if !envelope.Status {
fmt.Printf("API error: %s (HTTP %d): %s\n", envelope.ErrorCode, envelope.StatusCode, envelope.Message)
}
}Error response
{
"status": false,
"status_code": 401,
"error_code": "unauthorized",
"message": "Invalid API key.",
"data": {}
}This 401 example shows an invalid or revoked key. A missing key returns the same envelope with the message Authentication credentials were not provided.
Unknown JSON fields are rejected (400 invalid_request). An Idempotency-Key longer than 128 characters is also 400 invalid_request. Every response carries X-Request-ID; quote it when contacting support.
Rate limits
Limits use a sliding 60-second window. A 429 response is not billed and includes Retry-After in seconds (normally 60). Wait at least that long before trying again; immediate retries use your retry budget without helping the request succeed.
Request limits
| Scope | Limit |
|---|---|
| Look up hashed MSISDN, per API key | 100 / min |
| Look up hashed MSISDN, per source IP | 400 / min |
| Phone validation, per API key | 200 / min |
| Batch creation, per account | 10 / min |
Service keys cannot access batch jobs; API integrations require a tenant-owned API key.
For billable lookups, preserve the same Idempotency-Key and hash across retries, including after a network timeout. Generate the key once per logical lookup, not inside the retry loop. The Python, Node.js, and Go helpers retry 429 responses only; cURL also retries transient server errors. Handle other API responses in your application.
Handling rate limits (429)
# Modern cURL honors Retry-After for transient HTTP errors.
curl -i -X POST https://api.dehashit.com/api/v1/msisdn/decrypt/ \
-H "X-Api-Key: dh_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: payment-order-123" \
-d '{"msisdn_hash": "7132104d6aae9c3fac82095a42c2817952bca48e09d98d5bf4ac08218982fb90"}' \
--retry 3 --retry-max-time 240import time
import requests
def request_with_backoff(hash_hex: str, idempotency_key: str, max_attempts: int = 3):
url = "https://api.dehashit.com/api/v1/msisdn/decrypt/"
headers = {
"X-Api-Key": "dh_live_...",
"Idempotency-Key": idempotency_key, # Keep unchanged across retries.
}
for attempt in range(max_attempts):
res = requests.post(url, headers=headers, json={"msisdn_hash": hash_hex}, timeout=10)
if res.status_code != 429:
return res.json()
if attempt + 1 < max_attempts:
time.sleep(max(1, int(res.headers.get("Retry-After", "60"))))
raise RuntimeError("Rate limit retry budget exceeded.")async function requestWithBackoff(hashHex, idempotencyKey, maxAttempts = 3) {
const url = "https://api.dehashit.com/api/v1/msisdn/decrypt/";
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const res = await fetch(url, {
method: "POST",
headers: {
"X-Api-Key": "dh_live_...",
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey, // Keep unchanged across retries.
},
body: JSON.stringify({ msisdn_hash: hashHex }),
signal: AbortSignal.timeout(10_000),
});
if (res.status !== 429) return res.json();
const seconds = Math.max(1, Number(res.headers.get("Retry-After")) || 60);
await res.body?.cancel();
if (attempt + 1 < maxAttempts) {
await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
}
}
throw new Error("Rate limit retry budget exceeded.");
}package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"
)
func requestWithBackoff(hashHex, idempotencyKey string, maxAttempts int) (*http.Response, error) {
url := "https://api.dehashit.com/api/v1/msisdn/decrypt/"
body, _ := json.Marshal(map[string]string{"msisdn_hash": hashHex})
client := &http.Client{Timeout: 10 * time.Second}
for attempt := 0; attempt < maxAttempts; attempt++ {
req, _ := http.NewRequest("POST", url, bytes.NewReader(body))
req.Header.Set("X-Api-Key", "dh_live_...")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", idempotencyKey)
res, err := client.Do(req)
if err != nil || res.StatusCode != http.StatusTooManyRequests {
return res, err // Caller closes the response body.
}
seconds, err := strconv.Atoi(res.Header.Get("Retry-After"))
if err != nil || seconds < 1 {
seconds = 60
}
res.Body.Close()
if attempt + 1 < maxAttempts {
time.Sleep(time.Duration(seconds) * time.Second)
}
}
return nil, fmt.Errorf("rate limit retry budget exceeded")
}Rate limit response (429)
{
"status": false,
"status_code": 429,
"error_code": "rate_limited",
"message": "Rate limit exceeded. Back off and retry.",
"data": {}
}Requests exceeding these thresholds return HTTP 429 with error_code: "rate_limited" and a Retry-After: 60 header. Spread requests over time instead of sending large bursts. Authentication, invalid-input, balance, and key-conflict errors need application action rather than an automatic retry.
Look up hashed MSISDN
/api/v1/msisdn/decrypt/Look up a phone-number hash from an M-Pesa transaction callback. Billable on match only (KES 0.10 per match). Unmatched hashes (404), validation errors (400), rate limits (429), and service outages (503) are free.
Note: The endpoint path keeps the historic /decrypt/ name so existing M-Pesa integrations can adopt it without changes. Submit a supported M-Pesa phone-number hash to retrieve the matching phone number when available.
To safely handle network retries, send an Idempotency-Key header (up to 128 characters). Choose a unique key for each logical lookup, such as your payment ID, and keep that key and hash unchanged for every retry. A successful match is returned again without a second charge; using its key with another hash returns 422 idempotency_key_reused. Failed lookups do not consume the key. Replace the example key payment-order-123 in your integration.
Body parameters
| Name | Type | Required | Description |
|---|---|---|---|
| msisdn_hash | string | required | 64-character hexadecimal hash from the M-Pesa callback. |
Request
curl -X POST https://api.dehashit.com/api/v1/msisdn/decrypt/ \
-H "X-Api-Key: dh_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: payment-order-123" \
-d '{"msisdn_hash": "7132104d6aae9c3fac82095a42c2817952bca48e09d98d5bf4ac08218982fb90"}'import requests
r = requests.post(
"https://api.dehashit.com/api/v1/msisdn/decrypt/",
json={"msisdn_hash": "7132104d6aae9c3fac82095a42c2817952bca48e09d98d5bf4ac08218982fb90"},
headers={
"X-Api-Key": "dh_live_...",
"Idempotency-Key": "payment-order-123",
},
timeout=10,
)
payload = r.json()
if payload["status"]:
print(payload["data"]["msisdn"])
else:
print(r.status_code, payload.get("error_code"))const res = await fetch("https://api.dehashit.com/api/v1/msisdn/decrypt/", {
method: "POST",
headers: {
"X-Api-Key": "dh_live_...",
"Content-Type": "application/json",
"Idempotency-Key": "payment-order-123",
},
body: JSON.stringify({ msisdn_hash: "7132104d6aae9c3fac82095a42c2817952bca48e09d98d5bf4ac08218982fb90" }),
});
const payload = await res.json();
if (payload.status) {
console.log(payload.data.msisdn);
} else {
console.error(res.status, payload.error_code);
}package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
)
func main() {
body, _ := json.Marshal(map[string]string{
"msisdn_hash": "7132104d6aae9c3fac82095a42c2817952bca48e09d98d5bf4ac08218982fb90",
})
req, _ := http.NewRequest("POST",
"https://api.dehashit.com/api/v1/msisdn/decrypt/", bytes.NewReader(body))
req.Header.Set("X-Api-Key", "dh_live_...")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "payment-order-123")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
var out struct {
Status bool `json:"status"`
ErrorCode string `json:"error_code"`
Data struct {
MSISDN string `json:"msisdn"`
Country string `json:"country"`
Carrier string `json:"carrier"`
} `json:"data"`
}
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
panic(err)
}
if !out.Status {
fmt.Println(res.StatusCode, out.ErrorCode)
return
}
fmt.Println(out.Data.MSISDN, out.Data.Country, out.Data.Carrier)
}Response
{
"status": true,
"status_code": 0,
"message": "Data found",
"data": {
"msisdn": "254712345678",
"country": "Kenya",
"carrier": "Safaricom"
}
}{
"status": false,
"status_code": 404,
"error_code": "not_found",
"message": "No data found.",
"data": {}
}{
"status": false,
"status_code": 402,
"error_code": "insufficient_balance",
"message": "Insufficient balance. Top up your wallet to continue.",
"data": {}
}{
"status": false,
"status_code": 422,
"error_code": "idempotency_key_reused",
"message": "Key was used for a different request.",
"data": {}
}Validate phone number
/api/v1/validations/phone-validation/Validate a phone number and identify its issuing network operator. Always free. Supports Kenya (country_code: "254"). Non-Kenyan country codes return 404.
Validation checks number format, not whether the number is active or reachable. Carrier is based on the original numbering prefix and does not account for number portability; an unassigned prefix returns carrier: "unknown".
Body parameters
| Name | Type | Required | Description |
|---|---|---|---|
| phone_number | string | required | National or international format: 712345678, 0712345678, 254712345678, +254712345678, or 00254712345678. Spaces, dashes, and brackets are ignored. The response uses the normalized 254 format. |
| country_code | string | required | Use the string "254". Other numeric country codes return 404; a nonnumeric code returns 400. |
Request
curl -X POST https://api.dehashit.com/api/v1/validations/phone-validation/ \
-H "X-Api-Key: dh_live_..." -H "Content-Type: application/json" \
-d '{"phone_number": "712345678", "country_code": "254"}'import requests
r = requests.post(
"https://api.dehashit.com/api/v1/validations/phone-validation/",
json={"phone_number": "712345678", "country_code": "254"},
headers={"X-Api-Key": "dh_live_..."},
timeout=10,
)
print(r.json()["data"])const res = await fetch("https://api.dehashit.com/api/v1/validations/phone-validation/", {
method: "POST",
headers: { "X-Api-Key": "dh_live_...", "Content-Type": "application/json" },
body: JSON.stringify({ phone_number: "712345678", country_code: "254" }),
});
console.log((await res.json()).data);package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
)
func main() {
body, _ := json.Marshal(map[string]string{
"phone_number": "712345678",
"country_code": "254",
})
req, _ := http.NewRequest("POST",
"https://api.dehashit.com/api/v1/validations/phone-validation/",
bytes.NewReader(body))
req.Header.Set("X-Api-Key", "dh_live_...")
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
var out struct {
Status bool `json:"status"`
Data struct {
PhoneNumber string `json:"phone_number"`
Country string `json:"country"`
Carrier string `json:"carrier"`
Valid bool `json:"valid"`
} `json:"data"`
}
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
panic(err)
}
fmt.Println(out.Data.PhoneNumber, out.Data.Carrier, out.Data.Valid)
}Response
{
"status": true,
"status_code": 0,
"message": "Data found.",
"data": {
"phone_number": "254712345678",
"country": "Kenya",
"carrier": "Safaricom",
"valid": true,
"type": "mobile"
}
}{
"status": false,
"status_code": 404,
"error_code": "not_found",
"message": "No data found.",
"data": {}
}Batch decode
/api/v1/batch/Decode up to 5,000 phone-number hashes in one CSV upload. New uploads are processed during the request; completed jobs have data.job.status set to done. Each matched row costs KES 0.10; unmatched, invalid, and insufficient-balance rows are free. Duplicate matching rows are each billed separately.
Send X-Batch-Key to make retries safe. Keep it unchanged when retrying the same upload; a completed job is returned without processing or billing again. Reusing the key with different bytes returns 422 idempotency_key_reused; use a new key for a new file. While the same upload is processing, retries return 409 idempotency_key_in_progress.
CSV and headers
| Field | Requirement |
|---|---|
| Body | Raw UTF-8 CSV, up to 2 MiB and 5,000 data rows. Do not send multipart form data. |
| msisdn_hash | Required column containing a 64-character hexadecimal phone-number hash per row. |
| Content-Type | text/csv |
| X-Api-Key | Your API key. All batch job and results operations require authentication. |
| X-Batch-Key | Optional retry key, up to 128 characters. Recommended for every upload. |
| X-Batch-Filename | Optional filename for job metadata; stored up to 256 characters. |
Save this example as hashes.csv, or start from the CSV template.
hashes.csv
msisdn_hash
7132104d6aae9c3fac82095a42c2817952bca48e09d98d5bf4ac08218982fb90Request
curl -X POST https://api.dehashit.com/api/v1/batch/ \
-H "X-Api-Key: dh_live_..." -H "Content-Type: text/csv" \
-H "X-Batch-Key: campaign-2026-10-01" \
--data-binary @hashes.csvimport requests
with open("hashes.csv", "rb") as f:
r = requests.post(
"https://api.dehashit.com/api/v1/batch/",
data=f,
headers={
"X-Api-Key": "dh_live_...",
"Content-Type": "text/csv",
"X-Batch-Key": "campaign-2026-10-01",
},
timeout=30,
)
job = r.json()["data"]["job"] # {"status": "done", "matched_count": ...}import { readFileSync } from "node:fs";
const res = await fetch("https://api.dehashit.com/api/v1/batch/", {
method: "POST",
headers: {
"X-Api-Key": "dh_live_...",
"Content-Type": "text/csv",
"X-Batch-Key": "campaign-2026-10-01",
},
body: readFileSync("hashes.csv"),
});
const { data } = await res.json(); // data.job: { status, row_count, ... }package main
import (
"bytes"
"fmt"
"net/http"
"os"
)
func main() {
csv, err := os.ReadFile("hashes.csv")
if err != nil {
panic(err)
}
req, _ := http.NewRequest("POST",
"https://api.dehashit.com/api/v1/batch/", bytes.NewReader(csv))
req.Header.Set("X-Api-Key", "dh_live_...")
req.Header.Set("Content-Type", "text/csv")
req.Header.Set("X-Batch-Key", "campaign-2026-10-01")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
fmt.Println(res.Status)
}Response
{
"status": true,
"status_code": 0,
"message": "Batch ready.",
"data": {
"job": {
"id": 7,
"status": "done",
"row_count": 1,
"completed_rows": 1,
"matched_count": 1,
"unmatched_count": 0,
"invalid_count": 0,
"charged_cents": 10,
"filename": "",
"error": "",
"created_at": "2026-10-17T09:00:00",
"finished_at": "2026-10-17T09:00:01"
}
}
}msisdn_hash,msisdn,status,detail
7132104d6aae9c3fac82095a42c2817952bca48e09d98d5bf4ac08218982fb90,254712345678,match,SafaricomDownload results
Use the returned data.job.id to read the job or download its CSV. Results expire after 24 hours; save them promptly. API-key jobs are accessible only with the creating key within its organization. Browser-created jobs are scoped to the creating user and organization, across that user’s login sessions, and are separate from API-key jobs. Rotating a key does not grant the new key access to the old key’s jobs. Requests outside these scopes or for expired results return 404. Results that are not ready return 409.
| Method and path | Returns |
|---|---|
GET /api/v1/batch/ | The latest 50 jobs for the calling key or browser actor and organization, as an array in data. |
GET /api/v1/batch/{job_id}/ | A single job in data.job. |
GET /api/v1/batch/{job_id}/results/ | A CSV file, rather than a JSON envelope. |
Download a finished job
# Replace 7 with data.job.id from your upload response.
curl "https://api.dehashit.com/api/v1/batch/7/results/" \
-H "X-Api-Key: dh_live_..." \
--output batch-7-results.csvCheck each row’s status: match, no_match, invalid, or insufficient_balance. A finished job can include unpaid rows. After topping up, upload only those rows with a new batch key to avoid charging previously matched rows again.
Need a hand with your integration? Contact support.