Authentication#
EnConvert has two kinds of API key, and where your code runs decides which one you use. A private key (sk_) goes in the X-API-Key header from a server you control; a public key (pk_) is exchanged for a short-lived JWT that browser code sends as Authorization: Bearer <token>.
Choosing a key type#
| Private key | Public key + JWT | |
|---|---|---|
| Prefix | sk_ |
pk_ |
| Sent as | X-API-Key: sk_your_private_key |
Authorization: Bearer <token> |
| Runs in | servers, scripts, CI jobs, containers | browsers, embedded widgets, anything shipped to a client |
| Rejected when | the request carries an Origin header (403) |
it calls anything other than /v1/auth/token or /v1/auth/branding (403) |
| Reach | every endpoint: sync, async, batch, webhooks | one item per request, synchronous, presigned download |
| Scoping | optional allowed_endpoints |
allowed_domains plus allowed_endpoints |
| Lifetime | key valid until revoked | key valid until revoked, access token 1 hour, refresh cookie 7 days |
Pick by deployment target. A backend service, script, cron job, or internal tool takes a private key. A browser app or an embedded widget takes a public key with JWT. There is no way to hide a private key in frontend code: the gateway rejects it on the presence of an Origin header alone, whatever that header says.
A key is its prefix followed by a random token, 46 characters in all. The placeholder names in the examples below (sk_your_private_key, pk_your_public_key) stand in for that token; a real key has no environment segment such as live or test inside it. Anything shorter than 45 characters is rejected with 401 Invalid API Key format before the prefix is even read.
Only a SHA-256 hash of each key is stored on the server, along with a seven-character prefix stub so you can tell your keys apart in the dashboard.
Private keys#
Private keys are intended for server-side applications where your API key can be kept secret. They provide full access to all API endpoints and features.
- Header:
X-API-Key: sk_your_private_key - Access: Full access to all endpoints, including sync and async operations, batch processing, and all conversion types.
- Security: Keys are stored as SHA-256 hashes on the server. The plaintext key is shown only once at creation time.
No token exchange or session management is required. Include the key in each request:
curl -X POST https://api.enconvert.com/v1/convert/url-to-pdf \
-H "X-API-Key: sk_your_private_key" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com"}'
Header format#
Include your private key in the X-API-Key header with every request:
X-API-Key: sk_your_private_key
Private keys always begin with the sk_ prefix. You can generate and manage your keys from the EnConvert dashboard.
Example: file conversion#
Convert a JSON file to XML using a private key:
curl -X POST https://api.enconvert.com/v1/convert/json-to-xml \
-H "X-API-Key: sk_your_private_key" \
-F "[email protected]"
Response:
{
"presigned_url": "https://econverter.nyc3.cdn.digitaloceanspaces.com/...",
"object_key": "live/files/12345/json-to-xml/data_20250202_120530123.xml",
"filename": "data_20250202_120530123.xml",
"file_size": 1024,
"conversion_time_seconds": 0.45
}
presigned_url: a temporary, downloadable URL for retrieving the converted file.object_key: the storage path of the converted file (e.g.,live/files/12345/json-to-xml/...). This is not a URL.filename: the generated filename for the converted file.file_size: the size of the output file in bytes.conversion_time_seconds: the time taken to complete the conversion.
Those download URLs are short-lived. Their expiry and retention rules are on Signed URLs.
Endpoint restrictions#
By default, a private key has access to all API endpoints. You can optionally restrict a key to specific endpoints using the allowed_endpoints setting when you create it.
When allowed_endpoints is configured, the key will only be able to call the listed endpoints. Requests to any other endpoint will be rejected with a 403 Forbidden error: Endpoint '{path}' not allowed for this API key.
Example configuration:
{
"allowed_endpoints": [
"/v1/convert/url-to-pdf",
"/v1/convert/json-to-xml",
"/v1/convert/html-to-pdf"
]
}
This is useful when you want to issue a key with limited scope, for example, a key that can only perform PDF conversions.
A handful of paths stay reachable whatever the list says, because a key that can start a job has to be able to finish it:
/v1/auth/token,/v1/auth/verifyand/v1/whoami/v1/convert/status/{job_id},/v1/convert/batch/{batch_id}and/v1/convert/download/{object_key}/v1/extension/*, for JWT-authenticated requests- the per-job V2 paths for perceive, ingest and watch
A key created with the single entry ["*"] means every endpoint, including endpoints that ship after the key was made.
The list is fixed at creation time. There is no call that edits an existing key's restrictions, so narrowing or widening a key's scope means creating a new key and revoking the old one. See Keeping keys safe.
Origin header sent by browsers and will reject requests made with a private key from a browser environment with 403 Private API keys cannot be used from browsers. For client-side integrations, use a public key with JWT instead.
Public keys and JWT#
Public key JWT authentication lets client-side (browser) apps call the EnConvert API: you exchange your public key (pk_) for a short-lived JWT access token via POST /v1/auth/token, then send that token in the Authorization: Bearer <token> header on API requests. Because a public key is visible to end users, it cannot call the API directly. On its own it reaches exactly two paths, /v1/auth/token and /v1/auth/branding. Everything else returns 403 with a message telling you to exchange the key for a token first.
- Exchange your public key (
pk_) for a JWT access token by callingPOST /v1/auth/token. - Use the JWT token in the
Authorization: Bearer <token>header on API requests. - Refresh the token automatically before it expires using
POST /v1/auth/refresh. - Domain allowlisting ensures that only requests originating from your approved domains are accepted.
Step 1: exchange public key for JWT#
POST /v1/auth/token
| Header | Value | Description |
|---|---|---|
X-API-Key |
pk_your_public_key |
Your public API key |
The endpoint expects a JSON body object. Sending no body at all returns 422 with {"type":"missing","loc":["body"],"msg":"Field required"}, so send {} when you have nothing to pass. The one optional field is turnstile_token, which is verified only for requests coming from EnConvert's own widget origin and ignored everywhere else.
async function getToken() {
const response = await fetch("https://api.enconvert.com/v1/auth/token", {
method: "POST",
headers: {
"X-API-Key": "pk_your_public_key",
"Content-Type": "application/json",
},
body: JSON.stringify({}),
credentials: "include",
});
if (!response.ok) {
throw new Error(`Token exchange failed: ${response.status}`);
}
const data = await response.json();
return data.token;
}
credentials: "include" in the fetch options. This ensures the refresh token cookie is stored by the browser, which is required for automatic token refresh.
Response:
{
"token": "eyJhbGciOiJIUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 3600
}
The response also sets an HttpOnly cookie containing the refresh token. This cookie is managed automatically by the browser and is used when refreshing the access token.
A private key sent to this endpoint is refused with 400 Only public API keys can exchange for tokens. Private keys should be used directly. That is the API telling you to drop the exchange step, not a broken key.
Step 2: use the JWT token#
Include the JWT token in the Authorization header as a Bearer token on all subsequent API requests.
async function convertUrlToPdf(token, url) {
const response = await fetch("https://api.enconvert.com/v1/convert/url-to-pdf", {
method: "POST",
headers: {
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ url }),
});
return await response.json();
}
// Usage
const token = await getToken();
const result = await convertUrlToPdf(token, "https://example.com");
console.log(result.presigned_url);
Step 3: automatic token refresh#
Access tokens expire after an hour. Use the refresh endpoint to obtain a new access token without requiring the user to re-authenticate.
POST /v1/auth/refresh
The refresh token is sent automatically via the HttpOnly cookie that was set during the initial token exchange. No request body or additional headers are needed.
class EnconvertClient {
constructor(publicKey) {
this.publicKey = publicKey;
this.token = null;
this.tokenExpiry = null;
}
async getToken() {
const response = await fetch("https://api.enconvert.com/v1/auth/token", {
method: "POST",
headers: {
"X-API-Key": this.publicKey,
"X-Parent-Origin": window.location.origin,
"Content-Type": "application/json",
},
body: JSON.stringify({}),
credentials: "include",
});
const data = await response.json();
this.token = data.token;
// Set expiry to 55 minutes (refresh before the 1-hour expiry)
this.tokenExpiry = Date.now() + 55 * 60 * 1000;
return this.token;
}
async refreshToken() {
const response = await fetch("https://api.enconvert.com/v1/auth/refresh", {
method: "POST",
credentials: "include",
});
if (!response.ok) {
// Refresh token expired, re-authenticate
return await this.getToken();
}
const data = await response.json();
this.token = data.token;
this.tokenExpiry = Date.now() + 55 * 60 * 1000;
return this.token;
}
async getValidToken() {
if (!this.token || Date.now() >= this.tokenExpiry) {
if (this.token) {
return await this.refreshToken();
}
return await this.getToken();
}
return this.token;
}
async convert(endpoint, body) {
const token = await this.getValidToken();
const response = await fetch(`https://api.enconvert.com${endpoint}`, {
method: "POST",
headers: {
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
return await response.json();
}
}
// Usage
const client = new EnconvertClient("pk_your_public_key");
const result = await client.convert("/v1/convert/url-to-pdf", {
url: "https://example.com",
});
Four things to know before you debug a failing refresh:
- Refresh resolves your project from the cookie, then looks for any active public key on that project. Revoke every public key and refresh starts returning
401, even while the cookie is still inside its seven days. - The refresh route does not re-check the domain allowlist. That check happens when the token is minted.
- Refresh does not re-bind the new token to the key you originally used. If the project holds several public keys, the refreshed token can come back carrying a different key's restrictions.
- Token minting and refresh carry their own per-IP throttle, separate from your plan's rate limits. A client stuck in a refresh loop will feel it.
Token lifetimes#
| Token | Lifetime | Storage |
|---|---|---|
| Access token | 1 hour | Returned in the JSON response body; store in memory |
| Refresh token | 7 days | Set as an HttpOnly cookie; managed by the browser |
Domain allowlisting#
Public keys are restricted to specific domains configured in your dashboard.
Matching compares the host and port only. The scheme is stripped from both sides first, so https://example.com and http://example.com are the same origin as far as the allowlist is concerned. The port is not stripped and is part of the match.
- Exact match:
https://example.commatches the bare hostexample.comon any scheme. - Wildcard subdomains:
https://*.example.commatcheshttps://app.example.com,https://staging.example.com, and the apexhttps://example.comtoo. - Port-specific:
http://localhost:3000matches only that host and port.
| Allowlist entry | Matches | Does not match |
|---|---|---|
https://example.com |
https://example.com, http://example.com |
https://www.example.com |
https://*.example.com |
https://app.example.com, https://dev.example.com, https://example.com |
https://example.net |
http://localhost:3000 |
http://localhost:3000 |
http://localhost:8080 |
A request from an origin that is not on the list gets 403 Domain {origin} not authorized, and the project owner is emailed about it (at most once per key per 24 hours). If your inbox is filling up, a stale allowlist entry is the usual cause.
Two origins skip the domain check entirely: a chrome-extension://... origin, so browser extensions can call the API, and EnConvert's own widget origin, where the check moves to the widget's X-Parent-Origin validation instead.
Security features#
- Short-lived tokens: Access tokens expire after 1 hour, limiting the window of exposure if a token is compromised.
- HttpOnly refresh cookies: Refresh tokens are stored in
HttpOnlycookies, making them inaccessible to JavaScript and resistant to XSS attacks. - Domain restrictions: a browser request must carry an
Originon the allowlist before a token is issued. - No direct API access: Public keys alone cannot call conversion endpoints. A valid JWT is always required.
Public key restrictions#
Public key authentication has the following limitations compared to private keys:
- Synchronous only: Only synchronous conversion endpoints are available. Async mode and webhook callbacks are not;
notification_emailandcallback_urlare cleared on browser-key conversions. - Single item per request: Each request may convert only one URL or file. Sending an array returns
400 Public keys only support a single URL input. - Direct download: Responses provide a
presigned_urlfor immediate download. There is no option for custom storage destinations. - Job status yes, batch status no:
GET /v1/convert/status/{job_id}works with a browser token, which is how the widget recovers a result after a dropped connection.GET /v1/convert/batch/{batch_id}is refused with403 Batch status requires a private API key.
Batch submission itself is gated by your plan's batch limit rather than by key type, but since a browser key is capped at one item per request, batches in practice need a private key. See Batch Processing.
- Always store access tokens in memory only. Never persist them to
localStorageorsessionStorage. - Implement automatic token refresh to avoid interruptions during user sessions.
- Keep your allowlisted domains list as specific as possible. Avoid broad wildcards.
- Use
credentials: "include"on all fetch requests to ensure cookies are sent and received correctly. - Handle token refresh failures gracefully by falling back to a full re-authentication with the public key.
If you want the browser flow without writing any of it, the embeddable widget mints and refreshes its own tokens. See Web widgets.
Verify your credentials#
GET /v1/auth/verify checks whether your current authentication is valid and reports what the API thinks it is. It works with private keys sent in the X-API-Key header and with JWT bearer tokens sent in the Authorization header. A valid request returns your project_id, tier, key_type, and any domain or endpoint restrictions; an invalid or expired key or token returns 401 Unauthorized.
GET /v1/auth/verify
| Header | Value | Description |
|---|---|---|
X-API-Key |
sk_your_private_key |
Authenticate with a private key |
Authorization |
Bearer <token> |
Authenticate with a JWT token |
Use one of the two headers above, not both.
X-API-Key: pk_... to this endpoint returns 403, because a public key may only call /v1/auth/token and /v1/auth/branding. Mint a token first, then verify the token. This is the one case where a 403 here does not mean your key is broken.
With a private key:
curl https://api.enconvert.com/v1/auth/verify \
-H "X-API-Key: sk_your_private_key"
With a JWT token:
curl https://api.enconvert.com/v1/auth/verify \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..."
Response:
{
"authenticated": true,
"project_id": "12345",
"tier": "pro",
"key_type": "public",
"allowed_domains": ["https://example.com", "https://*.example.com"],
"allowed_endpoints": ["/v1/convert/url-to-pdf", "/v1/convert/jpeg-to-png"]
}
| Field | Type | Description |
|---|---|---|
authenticated |
boolean | Always true for a valid request |
project_id |
string | Your project ID |
tier |
string | Your subscription tier (e.g., free, starter, pro, business) |
key_type |
string | private, public, or dashboard |
allowed_domains |
array or null | Allowlisted domains (public keys only, null otherwise) |
allowed_endpoints |
array or null | Restricted endpoints (public keys only, null otherwise) |
Two details that trip people up. key_type has a third value, dashboard, which is what the backend mints for a signed-in dashboard or playground session; like a private key it reports both lists as null. And tier is the plan slug, not the name on the pricing page: a Studio subscription reports "tier": "pro". The slugs free, starter, pro, business and enterprise correspond to Founding, Indie, Studio, Production and Enterprise.
If the key or token is invalid or expired, the API returns a 401 Unauthorized error instead. The full list of authentication error messages is in Errors.
GET /v1/whoami#
There is a second, smaller identity endpoint. It requires a private key:
curl https://api.enconvert.com/v1/whoami \
-H "X-API-Key: sk_your_private_key"
{
"project_id": "12345",
"plan_slug": "pro"
}
It returns nothing else on purpose: no key type, no domains, no limits. A JWT or a public key gets 403 GET /v1/whoami requires a private API key (sk_...). Several integrations use it as their credential test, including the n8n node.
Use cases#
- Testing API keys: Confirm that a newly created key is active and correctly configured.
- Checking domain restrictions: Verify which domains are allowlisted for a public key.
- Debugging auth issues: Determine whether a request failure is caused by authentication or something else.
Keeping keys safe#
- Hashed storage: Private keys are stored on the server as SHA-256 hashes. The plaintext key is displayed only once at creation time. If you lose it, you must generate a new key.
- Environment variables: Store your key in an environment variable (e.g.,
ENCONVERT_API_KEY) rather than hardcoding it in your source code. - Scope at creation:
allowed_endpointsandallowed_domainsare set when the key is created and cannot be edited afterwards. Decide the scope before you click create.
Rotating a key#
Rotation is create-then-revoke, and in this order it costs no downtime:
- Create the new key in the dashboard with the scope you want.
- Deploy it, then confirm the new key is live with
GET /v1/auth/verify. - Revoke the old key.
Both keys work during step 2, so there is no window where your service is unauthenticated. Because restrictions are immutable, changing a key's scope is the same procedure as rotating it.
If a key leaks#
Revoke it first, then work out the blast radius. Revoking is the only kill switch, because a live key's scope cannot be narrowed; a revoked key is rejected with 401 API Key revoked.
- A leaked private key can call every endpoint the key was scoped to and spends your monthly ops. Revoke it, create a replacement, and check your usage in the dashboard for calls you did not make.
- A leaked public key is less urgent by design. It cannot call conversion endpoints at all, and it only mints tokens for origins on its allowlist. Tightening that allowlist means creating a narrower key and revoking the leaked one, since the list on an existing key cannot be edited.
- A leaked access token dies within the hour, and a browser cannot replay it from an origin other than the one it was issued to. Its refresh cookie is the longer-lived problem: refresh succeeds while the project has any active public key, so revoking the key the token came from does not kill the cookie unless it was your last public key.
Frequently asked questions#
How do I authenticate to a REST API with an X-API-Key header?#
Send your private key in the X-API-Key header on every request, for example X-API-Key: sk_your_private_key. Private keys grant full access to all endpoints (including sync and async operations, batch processing, and all conversion types) with no token exchange required.
What is the difference between sk_ and pk_ API keys?#
Keys with the sk_ prefix are private keys for server-to-server use and provide full API access via the X-API-Key header. Keys with the pk_ prefix are public keys for client-side (browser) apps: they cannot call the API directly and must first be exchanged for a short-lived JWT via POST /v1/auth/token.
Can I use my private API key (sk_) in a browser or mobile app?#
No. The API detects the Origin header sent by browsers and rejects requests made with private keys from browser environments with 403 Private API keys cannot be used from browsers. Use a public key (pk_) with the JWT flow for client-side integrations instead.
How do I get a JWT bearer token for client-side API authentication?#
Exchange your public key (pk_) for a JWT by calling POST /v1/auth/token with the key in the X-API-Key header and {} as the JSON body. Use the returned token in the Authorization: Bearer <token> header on API requests, and refresh it before expiry via POST /v1/auth/refresh.
Can I restrict a private API key to specific endpoints?#
Yes. Set allowed_endpoints when you create the key, listing paths such as /v1/convert/url-to-pdf. Requests to any endpoint not on the list are rejected with a 403 Forbidden error, apart from the auth, status, download and per-job paths that stay reachable for every key.
What happens if I lose my private API key?#
Private keys are stored on the server as SHA-256 hashes, and the plaintext key is displayed only once at creation time. If you lose it, you must generate a new key. You can create multiple keys and revoke old ones from the dashboard without downtime.
How long do access tokens and refresh tokens last?#
Access tokens expire after 1 hour and should be stored in memory only. Refresh tokens last 7 days and are set as an HttpOnly cookie managed by the browser.
Why does my token refresh fail without credentials: "include"?#
The refresh token is stored in an HttpOnly cookie set during the initial token exchange, and POST /v1/auth/refresh relies on the browser sending that cookie automatically. If you omit credentials: "include" from your fetch requests, the cookie is not stored or sent. When a refresh fails, fall back to a full re-authentication with your public key.
Can I use wildcard subdomains in the domain allowlist?#
Yes. https://*.example.com matches https://app.example.com, https://staging.example.com, and the apex https://example.com. Exact hosts and port-specific origins like http://localhost:3000 are also supported. Matching ignores the scheme but not the port.
Can I use a public key for async or batch conversions?#
No. Public key authentication supports synchronous conversion endpoints only, with a single URL or file per request; async mode, webhooks and batch status polling require a private key. Responses provide a presigned_url for immediate download.
How do I test if my API key is valid?#
Send a request to GET /v1/auth/verify with a private key in the X-API-Key header, or a JWT in the Authorization header. A valid credential returns authenticated: true along with your project_id and tier; an invalid or expired one returns 401 Unauthorized. A public key cannot be verified this way and returns 403.
Why are allowed_domains and allowed_endpoints null in the verify response?#
Both fields are populated for public keys only and return null for private keys and for dashboard sessions. For public keys, allowed_domains lists the allowlisted domains and allowed_endpoints lists any endpoint restrictions.
Which authentication method should I choose for my integration?#
Use a private key (sk_) for backend services, scripts, or internal tools, because it is simpler and gives full access. Use a public key (pk_) with JWT for browser-based apps or widgets, since it keeps credentials safe and restricts access to allowlisted domains.