REST API Overview

Call the Nexa platform directly from any language or framework using plain HTTP.

The Nexa REST API lets you integrate file processing, payments, and student records into any stack — Python, PHP, Ruby, Go, plain curl, or anything else that can make an HTTP request. No SDK required.

Base URL

https://nexa-ed.com

All endpoints are versioned implicitly through breaking-change-free evolution. When a breaking change is necessary a versioned prefix (/v2/...) will be introduced with advance notice.

Authentication

Every request must carry your tenant API key in the x-api-key header.

curl https://nexa-ed.com/api/file-processing/status \
  -H "x-api-key: nxa_live_your_key_here"

Get your API key from the Nexa dashboard → API key.

Warning

Never expose your API key in client-side code or public repositories. Route all Nexa API calls through your server.

User-scoped requests

Endpoints that return per-user data (documents, student records, analysis jobs) also accept an optional x-user-id header. When provided, results are filtered to that user.

curl https://nexa-ed.com/api/rpc/documents/getUserDocuments \
  -X POST \
  -H "x-api-key: nxa_live_..." \
  -H "x-user-id: user_abc123" \
  -H "Content-Type: application/json" \
  -d '{}'

Request format

  • All POST / PATCH bodies must be JSON with Content-Type: application/json.
  • GET parameters are passed as query string values.
  • File uploads use multipart/form-data.

Response format

Successful responses return JSON. The shape varies by endpoint — see the individual reference pages.

Error format

All errors follow a consistent envelope:

{
  "error": {
    "code": "UNAUTHORIZED",
    "message": "Invalid or missing API key"
  }
}
HTTP statusWhen
400Malformed request body or missing required fields
401Missing or invalid x-api-key
403Valid key but insufficient permissions
404Resource not found
409Conflict (e.g. duplicate payment reference)
422Validation failed
429Rate limit exceeded
500Internal server error

Rate limits

All limits use a sliding window algorithm scoped per tenant (by API key), except the RPC endpoint which is scoped per user.

EndpointLimitScope
POST /api/file-processing/extract20 req / minper tenant
POST /api/payments/initialize10 req / minper tenant
POST /api/student-emails/bulk5 req / minper tenant
POST /api/rpc/[...orpc]120 req / minper user
All other routes60 req / minper tenant

Response headers

Every response (including successful ones) includes rate limit headers:

HeaderDescription
X-RateLimit-LimitMax requests allowed in the window
X-RateLimit-RemainingRequests remaining in the current window
X-RateLimit-ResetUnix timestamp (milliseconds) when the window resets

429 response

When the limit is exceeded you receive HTTP 429 with an additional Retry-After header:

HTTP/1.1 429 Too Many Requests
Retry-After: 42
X-RateLimit-Limit: 20
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1742000042000
{
  "success": false,
  "message": "Too many requests — please slow down",
  "errorType": "rate_limit_exceeded"
}

Retry strategy

Use Retry-After as your wait floor before retrying:

if (res.status === 429) {
  const wait = parseInt(res.headers.get("retry-after") ?? "5", 10);
  await new Promise((r) => setTimeout(r, wait * 1_000));
  // retry request
}
if resp.status_code == 429:
    retry_after = int(resp.headers.get("Retry-After", 5))
    time.sleep(retry_after)
    # retry request

Info

The @nexa-ed/sdk handles 429 automatically with one built-in retry using the Retry-After header. If the retry also fails, a NexaError with code RATE_LIMITED is thrown.

Webhook verification

When Nexa delivers webhook events to your server, each request is signed with HMAC-SHA256. Verify the signature before processing any event.

Headers sent by Nexa:

HeaderValue
x-nexa-signatureHex-encoded HMAC-SHA256 of body + timestamp
x-nexa-timestampUnix timestamp (seconds) when the event was signed

Verification (Python example):

import hmac, hashlib, time, json
 
def verify_nexa_webhook(raw_body: str, signature: str, timestamp: str, secret: str) -> dict:
    now = int(time.time())
    event_time = int(timestamp)
    if event_time > 1e12:
        event_time = event_time // 1000  # milliseconds → seconds
 
    if abs(now - event_time) > 300:
        raise ValueError("Webhook event is too old (replay protection)")
 
    expected = hmac.new(
        secret.encode(),
        (raw_body + timestamp).encode(),
        hashlib.sha256,
    ).hexdigest()
 
    if not hmac.compare_digest(expected, signature):
        raise ValueError("Invalid webhook signature")
 
    return json.loads(raw_body)

Verification (Go example):

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "fmt"
    "math"
    "strconv"
    "time"
)
 
func verifyNexaWebhook(rawBody, signature, timestamp, secret string) (map[string]any, error) {
    ts, err := strconv.ParseInt(timestamp, 10, 64)
    if err != nil {
        return nil, fmt.Errorf("invalid timestamp: %w", err)
    }
    if ts > 1e12 { ts /= 1000 }
    if math.Abs(float64(time.Now().Unix()-ts)) > 300 {
        return nil, fmt.Errorf("webhook too old")
    }
 
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(rawBody + timestamp))
    expected := hex.EncodeToString(mac.Sum(nil))
 
    if !hmac.Equal([]byte(expected), []byte(signature)) {
        return nil, fmt.Errorf("invalid signature")
    }
 
    var event map[string]any
    if err := json.Unmarshal([]byte(rawBody), &event); err != nil {
        return nil, fmt.Errorf("invalid JSON body: %w", err)
    }
    return event, nil
}

Quick reference

CategoryDocs
File processingREST API → File Processing
PaymentsREST API → Payments
Documents & recordsREST API → Documents
Student email provisioningREST API → Student Emails

On this page