REST API v1 · free · no keys · CORS enabled
Free Signature API
The checks behind the ecdsa.com tools, callable from scripts and CI: verify ECDSA signatures, convert DER ⇄ raw, decode and verify ES256/ES384/ES512 JWTs, decode X.509 certificates, grade a domain's TLS signature health. Plain JSON over HTTPS — https://ecdsa.com/api/v1
$ curl -s https://ecdsa.com/api/v1Machine-readable spec: /api/v1/openapi.json (OpenAPI 3.1)
Free, no keys
No signup, no tokens, no quotas to manage. Send a request, get JSON back.
60 requests/min per IP
Generous for scripts and CI. The health endpoint adds a 10/min budget because it scans outward. Limits are per server instance.
CORS open
Access-Control-Allow-Origin: * on every response — call it straight from a browser, a doc page or a serverless function.
Never send private keys
Requests containing a PEM PRIVATE KEY block or a JWK with "d" are rejected with private_key_rejected. For anything secret, use the local browser tools — they never transmit your input.
Nothing logged
Request bodies are parsed in memory and never written to logs.
Endpoints
All endpoints live under https://ecdsa.com/api/v1. POST bodies are JSON (≤100KB); every response is JSON; errors come back as { "error": { "code", "message" } } with a matching HTTP status. The examples below are real and runnable — the sample signature verifies, the sample JWT checks out against its key.
/api/v1/verify
Verify an ECDSA signature
Checks a signature over a message (or a precomputed digest) against a public key. Curves: P-256, P-384, P-521, secp256k1. The signature format (ASN.1 DER or raw r‖s) and its encoding (hex, base64, base64url) are auto-detected. A signature that simply does not verify returns valid: false with HTTP 200 — only unparseable input is a 400.
| Field | Type | Required | Description |
|---|---|---|---|
| publicKey | string | yes | PEM "PUBLIC KEY" (SPKI), an EC JWK as JSON, or a bare SEC1 point in hex/base64. |
| message | string | yes | The signed message. |
| signature | string | yes | DER or raw r‖s signature; hex, base64 or base64url. |
| hash | "SHA-256" | "SHA-384" | "SHA-512" | no | Digest applied to the message. Default SHA-256; ignored when messageIsDigest is true. |
| curve | "auto" | "P-256" | "P-384" | "P-521" | "secp256k1" | no | Force a curve. Default "auto" — inferred from the key. |
| messageEncoding | "utf8" | "hex" | "base64" | no | How the message string encodes bytes. Default utf8. |
| messageIsDigest | boolean | no | When true, the message bytes are used as the digest directly (no hashing). Default false. |
Response: { valid, curve, curvesTried, keySource, signatureFormat, details: { r, s, highS }, notes[] }.
curl -s https://ecdsa.com/api/v1/verify \
-H 'content-type: application/json' \
-d '{"publicKey":"-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE4+8TCP3Hop/Acb8L7reksO9flyt1\nsUhvEqUs7a45RVDRDtAIZdTnOJcAhTCQvdzUBO4KUyxSRcZm6ro+2RGfSQ==\n-----END PUBLIC KEY-----","message":"The quick brown fox jumps over the lazy dog","signature":"3045022100df542bb71e0e3019086f1b1ba51f1aa0ec5e6afca05c3f5a7f4f02f5b7c39d24022030c59f1ac6d8c6893fb2a5ac8dfd9388d31ebd3382510dcfb7f28f9182eb1131"}'const res = await fetch("https://ecdsa.com/api/v1/verify", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
"publicKey": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE4+8TCP3Hop/Acb8L7reksO9flyt1\nsUhvEqUs7a45RVDRDtAIZdTnOJcAhTCQvdzUBO4KUyxSRcZm6ro+2RGfSQ==\n-----END PUBLIC KEY-----",
"message": "The quick brown fox jumps over the lazy dog",
"signature": "3045022100df542bb71e0e3019086f1b1ba51f1aa0ec5e6afca05c3f5a7f4f02f5b7c39d24022030c59f1ac6d8c6893fb2a5ac8dfd9388d31ebd3382510dcfb7f28f9182eb1131"
}),
});
console.log(await res.json());import requests
r = requests.post(
"https://ecdsa.com/api/v1/verify",
json={
"publicKey": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE4+8TCP3Hop/Acb8L7reksO9flyt1\nsUhvEqUs7a45RVDRDtAIZdTnOJcAhTCQvdzUBO4KUyxSRcZm6ro+2RGfSQ==\n-----END PUBLIC KEY-----",
"message": "The quick brown fox jumps over the lazy dog",
"signature": "3045022100df542bb71e0e3019086f1b1ba51f1aa0ec5e6afca05c3f5a7f4f02f5b7c39d24022030c59f1ac6d8c6893fb2a5ac8dfd9388d31ebd3382510dcfb7f28f9182eb1131"
},
)
print(r.json())Try it
/api/v1/convert
Convert DER ⇄ raw r‖s
Decodes a signature in either format and returns both encodings, plus r, s and the high-S flag. Warnings report DER canonicality problems (BER lengths, missing 0x00 pads, trailing sighash bytes) and curve-ambiguity notes. normalize: "low-s" replaces a high-S s with n − s — the canonical form Bitcoin-family consensus rules require.
| Field | Type | Required | Description |
|---|---|---|---|
| signature | string | yes | DER or raw r‖s signature; hex, base64 or base64url (auto-detected). |
| curve | "P-256" | "P-384" | "P-521" | "secp256k1" | no | Curve for component sizing and the high-S check. Inferred from the length when omitted (a 64-byte raw signature is ambiguous between P-256 and secp256k1). |
| normalize | "low-s" | no | Replace a high-S s with its low-S equivalent n − s. |
Response: { inputFormat, curve, der: { hex, base64 }, raw: { hex, base64, base64url }, r, s, highS, warnings[] }.
curl -s https://ecdsa.com/api/v1/convert \
-H 'content-type: application/json' \
-d '{"signature":"3045022100df542bb71e0e3019086f1b1ba51f1aa0ec5e6afca05c3f5a7f4f02f5b7c39d24022030c59f1ac6d8c6893fb2a5ac8dfd9388d31ebd3382510dcfb7f28f9182eb1131"}'const res = await fetch("https://ecdsa.com/api/v1/convert", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
"signature": "3045022100df542bb71e0e3019086f1b1ba51f1aa0ec5e6afca05c3f5a7f4f02f5b7c39d24022030c59f1ac6d8c6893fb2a5ac8dfd9388d31ebd3382510dcfb7f28f9182eb1131"
}),
});
console.log(await res.json());import requests
r = requests.post(
"https://ecdsa.com/api/v1/convert",
json={
"signature": "3045022100df542bb71e0e3019086f1b1ba51f1aa0ec5e6afca05c3f5a7f4f02f5b7c39d24022030c59f1ac6d8c6893fb2a5ac8dfd9388d31ebd3382510dcfb7f28f9182eb1131"
},
)
print(r.json())Try it
/api/v1/jwt/decode
Decode a JWT (no signature check)
Decodes a compact JWS token into header and payload, enriches the exp / iat / nbf time claims with UTC and relative renderings, and classifies the algorithm. The signature is NOT verified — the response says verified: false explicitly. Decoding a token proves nothing about who issued it.
| Field | Type | Required | Description |
|---|---|---|---|
| token | string | yes | The compact JWS token. A leading "Bearer " and whitespace are stripped. |
Response: { header, payload, signaturePresent, signatureBytes, timeClaims: { exp, iat, nbf, status }, algInfo, verified: false, note }.
curl -s https://ecdsa.com/api/v1/jwt/decode \
-H 'content-type: application/json' \
-d '{"token":"eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImNvcHBlci1leGFtcGxlLTEifQ.eyJpc3MiOiJodHRwczovL2lzc3Vlci5leGFtcGxlLmNvbSIsInN1YiI6InVzZXJfNDIiLCJhdWQiOiJlY2RzYSIsImlhdCI6MTc4NzE0MDgwMCwiZXhwIjo0MDcwOTA4ODAwfQ.fQpTQxkahGRlZp-U71-xNk68mpZH23dhn9QoysMLmZfBx_e-vd9sCgh7sJ4eXu0-WbQSNqzmVPyUKErCJx4DHw"}'const res = await fetch("https://ecdsa.com/api/v1/jwt/decode", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
"token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImNvcHBlci1leGFtcGxlLTEifQ.eyJpc3MiOiJodHRwczovL2lzc3Vlci5leGFtcGxlLmNvbSIsInN1YiI6InVzZXJfNDIiLCJhdWQiOiJlY2RzYSIsImlhdCI6MTc4NzE0MDgwMCwiZXhwIjo0MDcwOTA4ODAwfQ.fQpTQxkahGRlZp-U71-xNk68mpZH23dhn9QoysMLmZfBx_e-vd9sCgh7sJ4eXu0-WbQSNqzmVPyUKErCJx4DHw"
}),
});
console.log(await res.json());import requests
r = requests.post(
"https://ecdsa.com/api/v1/jwt/decode",
json={
"token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImNvcHBlci1leGFtcGxlLTEifQ.eyJpc3MiOiJodHRwczovL2lzc3Vlci5leGFtcGxlLmNvbSIsInN1YiI6InVzZXJfNDIiLCJhdWQiOiJlY2RzYSIsImlhdCI6MTc4NzE0MDgwMCwiZXhwIjo0MDcwOTA4ODAwfQ.fQpTQxkahGRlZp-U71-xNk68mpZH23dhn9QoysMLmZfBx_e-vd9sCgh7sJ4eXu0-WbQSNqzmVPyUKErCJx4DHw"
},
)
print(r.json())Try it
/api/v1/jwt/verify
Verify an ES256/ES384/ES512 JWT
Verifies the token's ECDSA signature against a public key. Only the ES* family is supported — HS*, RS*/PS*, EdDSA, ES256K and "none" return 400 with code unsupported_algorithm and an explanation. valid reflects the cryptographic check only; expired tokens are flagged in explanations but still report their signature honestly.
| Field | Type | Required | Description |
|---|---|---|---|
| token | string | yes | Compact JWS token with alg ES256, ES384 or ES512. |
| publicKey | string | yes | PEM "PUBLIC KEY" (SPKI), an EC JWK as JSON, or a bare SEC1 point in hex/base64. |
Response: { valid, alg, explanations[] }.
curl -s https://ecdsa.com/api/v1/jwt/verify \
-H 'content-type: application/json' \
-d '{"token":"eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImNvcHBlci1leGFtcGxlLTEifQ.eyJpc3MiOiJodHRwczovL2lzc3Vlci5leGFtcGxlLmNvbSIsInN1YiI6InVzZXJfNDIiLCJhdWQiOiJlY2RzYSIsImlhdCI6MTc4NzE0MDgwMCwiZXhwIjo0MDcwOTA4ODAwfQ.fQpTQxkahGRlZp-U71-xNk68mpZH23dhn9QoysMLmZfBx_e-vd9sCgh7sJ4eXu0-WbQSNqzmVPyUKErCJx4DHw","publicKey":"{\n \"kty\": \"EC\",\n \"crv\": \"P-256\",\n \"x\": \"A4KIXkvTVuOzkR5Mzgsy8n_VSSIlephF1ViMxDYTUx4\",\n \"y\": \"Py8AMAM_6yWsOTbv8eHCvKDfObpZmTHy5P4Jz30iMuU\",\n \"kid\": \"copper-example-1\"\n}"}'const res = await fetch("https://ecdsa.com/api/v1/jwt/verify", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
"token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImNvcHBlci1leGFtcGxlLTEifQ.eyJpc3MiOiJodHRwczovL2lzc3Vlci5leGFtcGxlLmNvbSIsInN1YiI6InVzZXJfNDIiLCJhdWQiOiJlY2RzYSIsImlhdCI6MTc4NzE0MDgwMCwiZXhwIjo0MDcwOTA4ODAwfQ.fQpTQxkahGRlZp-U71-xNk68mpZH23dhn9QoysMLmZfBx_e-vd9sCgh7sJ4eXu0-WbQSNqzmVPyUKErCJx4DHw",
"publicKey": "{\n \"kty\": \"EC\",\n \"crv\": \"P-256\",\n \"x\": \"A4KIXkvTVuOzkR5Mzgsy8n_VSSIlephF1ViMxDYTUx4\",\n \"y\": \"Py8AMAM_6yWsOTbv8eHCvKDfObpZmTHy5P4Jz30iMuU\",\n \"kid\": \"copper-example-1\"\n}"
}),
});
console.log(await res.json());import requests
r = requests.post(
"https://ecdsa.com/api/v1/jwt/verify",
json={
"token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImNvcHBlci1leGFtcGxlLTEifQ.eyJpc3MiOiJodHRwczovL2lzc3Vlci5leGFtcGxlLmNvbSIsInN1YiI6InVzZXJfNDIiLCJhdWQiOiJlY2RzYSIsImlhdCI6MTc4NzE0MDgwMCwiZXhwIjo0MDcwOTA4ODAwfQ.fQpTQxkahGRlZp-U71-xNk68mpZH23dhn9QoysMLmZfBx_e-vd9sCgh7sJ4eXu0-WbQSNqzmVPyUKErCJx4DHw",
"publicKey": "{\n \"kty\": \"EC\",\n \"crv\": \"P-256\",\n \"x\": \"A4KIXkvTVuOzkR5Mzgsy8n_VSSIlephF1ViMxDYTUx4\",\n \"y\": \"Py8AMAM_6yWsOTbv8eHCvKDfObpZmTHy5P4Jz30iMuU\",\n \"kid\": \"copper-example-1\"\n}"
},
)
print(r.json())Try it
/api/v1/cert/decode
Decode an X.509 certificate or chain
Accepts PEM with one or more CERTIFICATE blocks (paste a whole chain) or a single DER certificate as base64/hex. Each certificate comes back as a structured summary — subject, issuer, validity, key and signature algorithms, SAN, key usage, SHA-256 fingerprint — plus assessments: weak signature hashes, expiry, CA/Browser Forum lifetime limits, post-quantum readiness.
| Field | Type | Required | Description |
|---|---|---|---|
| certificate | string | yes | PEM CERTIFICATE block(s) or one DER certificate as base64/hex. |
Response: { certificates: [ …summary with assessments[] ], notes[] }.
curl -s https://ecdsa.com/api/v1/cert/decode \
-H 'content-type: application/json' \
-d '{"certificate":"-----BEGIN CERTIFICATE-----\nMIICFjCCAbugAwIBAgIIBKGyw9Tl9gcwCgYIKoZIzj0EAwIwSDEXMBUGA1UEAxMO\nZGVtby5lY2RzYS5jb20xIDAeBgNVBAoTF0VDRFNBLmNvbSBTaWduYXR1cmUgTGFi\nMQswCQYDVQQGEwJVUzAeFw0yNjAxMTUwMDAwMDBaFw0yNzAxMTUwMDAwMDBaMEgx\nFzAVBgNVBAMTDmRlbW8uZWNkc2EuY29tMSAwHgYDVQQKExdFQ0RTQS5jb20gU2ln\nbmF0dXJlIExhYjELMAkGA1UEBhMCVVMwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNC\nAASOtEAsuQh2j8SNY/hVbkvFlXwHUzLXZjcTsaEcuwwvXCVsldoaP1Orr1h16iU+\nqjEAP2gmZSM2XbSN3B+h59Lko4GOMIGLMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/\nBAQDAgeAMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjAtBgNVHREEJjAk\ngg5kZW1vLmVjZHNhLmNvbYISd3d3LmRlbW8uZWNkc2EuY29tMB0GA1UdDgQWBBTp\nEL0Utu1R8qzHZ9IWC2WP789lqTAKBggqhkjOPQQDAgNJADBGAiEAjad1xoTy9SXd\nd8DnZMIA4jkASCGkYSc1R9io7mj83NcCIQDE0GDEHRLcXxZVdZgDcJjSPRx6cLqM\n4v4zJDEFLZ5reA==\n-----END CERTIFICATE-----"}'const res = await fetch("https://ecdsa.com/api/v1/cert/decode", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
"certificate": "-----BEGIN CERTIFICATE-----\nMIICFjCCAbugAwIBAgIIBKGyw9Tl9gcwCgYIKoZIzj0EAwIwSDEXMBUGA1UEAxMO\nZGVtby5lY2RzYS5jb20xIDAeBgNVBAoTF0VDRFNBLmNvbSBTaWduYXR1cmUgTGFi\nMQswCQYDVQQGEwJVUzAeFw0yNjAxMTUwMDAwMDBaFw0yNzAxMTUwMDAwMDBaMEgx\nFzAVBgNVBAMTDmRlbW8uZWNkc2EuY29tMSAwHgYDVQQKExdFQ0RTQS5jb20gU2ln\nbmF0dXJlIExhYjELMAkGA1UEBhMCVVMwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNC\nAASOtEAsuQh2j8SNY/hVbkvFlXwHUzLXZjcTsaEcuwwvXCVsldoaP1Orr1h16iU+\nqjEAP2gmZSM2XbSN3B+h59Lko4GOMIGLMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/\nBAQDAgeAMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjAtBgNVHREEJjAk\ngg5kZW1vLmVjZHNhLmNvbYISd3d3LmRlbW8uZWNkc2EuY29tMB0GA1UdDgQWBBTp\nEL0Utu1R8qzHZ9IWC2WP789lqTAKBggqhkjOPQQDAgNJADBGAiEAjad1xoTy9SXd\nd8DnZMIA4jkASCGkYSc1R9io7mj83NcCIQDE0GDEHRLcXxZVdZgDcJjSPRx6cLqM\n4v4zJDEFLZ5reA==\n-----END CERTIFICATE-----"
}),
});
console.log(await res.json());import requests
r = requests.post(
"https://ecdsa.com/api/v1/cert/decode",
json={
"certificate": "-----BEGIN CERTIFICATE-----\nMIICFjCCAbugAwIBAgIIBKGyw9Tl9gcwCgYIKoZIzj0EAwIwSDEXMBUGA1UEAxMO\nZGVtby5lY2RzYS5jb20xIDAeBgNVBAoTF0VDRFNBLmNvbSBTaWduYXR1cmUgTGFi\nMQswCQYDVQQGEwJVUzAeFw0yNjAxMTUwMDAwMDBaFw0yNzAxMTUwMDAwMDBaMEgx\nFzAVBgNVBAMTDmRlbW8uZWNkc2EuY29tMSAwHgYDVQQKExdFQ0RTQS5jb20gU2ln\nbmF0dXJlIExhYjELMAkGA1UEBhMCVVMwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNC\nAASOtEAsuQh2j8SNY/hVbkvFlXwHUzLXZjcTsaEcuwwvXCVsldoaP1Orr1h16iU+\nqjEAP2gmZSM2XbSN3B+h59Lko4GOMIGLMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/\nBAQDAgeAMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjAtBgNVHREEJjAk\ngg5kZW1vLmVjZHNhLmNvbYISd3d3LmRlbW8uZWNkc2EuY29tMB0GA1UdDgQWBBTp\nEL0Utu1R8qzHZ9IWC2WP789lqTAKBggqhkjOPQQDAgNJADBGAiEAjad1xoTy9SXd\nd8DnZMIA4jkASCGkYSc1R9io7mj83NcCIQDE0GDEHRLcXxZVdZgDcJjSPRx6cLqM\n4v4zJDEFLZ5reA==\n-----END CERTIFICATE-----"
},
)
print(r.json())Try it
/api/v1/health/{domain}
Domain TLS signature health
JSON version of the Signature Health report: connects to the domain on port 443, examines the served chain, algorithms, protocol versions and lifetimes, and returns a letter grade with findings. Results are cached for one hour. Only public DNS hostnames are accepted — IP addresses, single-label names and private/reserved addresses are refused. Because the scan makes outbound connections, this endpoint has an extra limit of 10 requests/min per IP.
| Field | Type | Required | Description |
|---|---|---|---|
| domain | string (path) | yes | Public DNS hostname, e.g. "github.com". |
Response: { domain, grade: { letter, score }, findings[], scannedAt, cached, cacheTtlSeconds, reportUrl }.
curl -s https://ecdsa.com/api/v1/health/github.comconst res = await fetch("https://ecdsa.com/api/v1/health/github.com");
console.log(await res.json());import requests
r = requests.get("https://ecdsa.com/api/v1/health/github.com")
print(r.json())Try it
Rate limits
60 requests per minute per IP across the whole API, plus an extra 10 requests per minute budget for /health/{domain} (it opens outbound TLS connections). Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset; exceeding a limit returns 429 with a Retry-After header. Honest fine print: counters are in-memory and per server instance, so under horizontal scaling the effective allowance can be somewhat higher — treat the documented numbers as the intended budget, not a hard ceiling. If you need more for something legitimate, back off politely and cache on your side; the health endpoint is already cached for an hour per domain.
Errors
Every error is JSON with a machine-readable code and a human-readable message that says exactly what was wrong with the input — the same explanations the browser tools show.
| Code | HTTP | Meaning |
|---|---|---|
| invalid_json | 400 | The body is not a JSON object. |
| missing_field | 400 | A required field is absent or empty. |
| invalid_input | 400 | A field could not be parsed — the message explains exactly why. |
| private_key_rejected | 400 | The request contains private-key material. Never send private keys anywhere. |
| unsupported_algorithm | 400 | /jwt/verify received a non-ES* token. |
| invalid_domain | 400 | /health received something other than a public DNS hostname. |
| scan_dns_error / scan_private_address | 400 | The domain does not resolve, or resolves to a private/reserved address. |
| payload_too_large | 413 | The body exceeds 100KB. |
| rate_limited | 429 | Too many requests — see Retry-After. |
| scan_unreachable / scan_tls_error | 502 | The domain could not be reached, or the TLS handshake failed. |
| scan_timeout | 504 | The scan timed out. |
| internal_error | 500 | Unexpected server error. |
Changelog
Initial public release: /verify, /convert, /jwt/decode, /jwt/verify, /cert/decode, /health/{domain} and the OpenAPI 3.1 spec. The API is versioned in the path; breaking changes would ship as /api/v2 — v1 responses may gain fields but will not lose or change them.