Curiosity

Calling endpoints externally

This page is the wire-protocol guide for invoking a Workspace custom endpoint from outside the workspace process. It focuses on the one detail that trips most clients up: HTTP 202 Accepted means "still working — poll again," not "queued for later." Everything else is plain JSON over HTTPS.

For the conceptual map of API surfaces, see API Overview. For tokens, see Token scopes. For writing the endpoint itself, see Custom Endpoints.

The request

There are two routes; pick the one that matches your token type:

POST /api/endpoints/run/{name}            # session JWT or API token
POST /api/endpoints/token/run/{name}      # endpoint token

Headers:

Authorization: Bearer <token>
Content-Type:  application/json
Accept:        application/json

The body is whatever your endpoint's Body.FromJson<T>() expects.

The response — three cases

The server makes one decision per request: did the endpoint finish inside its short server-side wait window?

Status Meaning Body
200 OK The endpoint completed. The endpoint's return value (JSON, text, or a file payload).
202 Accepted Still running. Poll again by re-POSTing to the same URL. Empty.
500 Internal Server Error The endpoint threw. Exception message as plain text. See Error codes.
No separate polling endpoint

There is no separate polling endpoint, no job id, no Location header, no Retry-After. The pollable identity of the in-flight task is (endpoint, body, caller). The server hashes that into a cache key and returns it on every response as X-MSK-ENDPOINT-KEY. On a subsequent poll you may either:

  1. Re-POST the same body — the hash matches and you hit the same task. Simplest path; this is what the Curiosity.Library SDK does.
  2. Echo the cache key, drop the body — send X-MSK-ENDPOINT-KEY: <key> as a request header and POST with an empty body. The server short-circuits to the cached task. This is what the Workspace front-end does; it saves bandwidth when bodies are large.

Either way, keep the same URL and Authorization header for the duration of the loop.

Response headers on 202

Header Purpose
X-MSK-ENDPOINT-KEY Cache key for the in-flight task. Capture it and echo it back as a request header on subsequent polls to skip resending the body. Also useful in support requests alongside the trace id.
CalculationProgress Optional free-form progress string set by the endpoint (e.g., "42 / 1000 records processed"). May be empty. Surface it to end-users for UX.

The polling loop

Keep polling

Keep polling. If the server stops seeing requests for a given cache key for ~5 seconds it pauses the task; after ~1 minute of silence it cancels it. Stopping your poll loop is the cancellation mechanism — there is no explicit cancel call.

The minimum viable loop:

  1. POST the request.
  2. If 200, parse the body, done.
  3. If 202, capture the X-MSK-ENDPOINT-KEY response header (and surface CalculationProgress if present), sleep 1 second, then poll again. Either re-POST the same body, or echo the captured key as a request header and POST with an empty body.
  4. If 500 (or any other non-2xx), surface the failure — the body is the exception message.

Optional — letting the server wait longer

Set X-MSK-RETRY: <integer> on each poll. The server uses it to scale how long it blocks before giving up and returning 202 (1 s at retry 0, scaling up to ~15 s on later retries). This reduces the number of round-trips when your endpoint takes seconds-to-minutes. It is optional; clients that don't send it get the 1 s default.

The SDK ships a client that handles the 202 loop, cancellation, and JSON (de)serialization for you. Add the package:

dotnet add package Curiosity.Library

Then:

using System.Threading;
using Curiosity.Library;

var client = new EndpointsClient(
    baseUrl:       "https://workspace.example.com",
    endpointToken: Environment.GetEnvironmentVariable("WORKSPACE_ENDPOINT_TOKEN"));

var request = new {
    query      = "screen flicker after firmware update",
    productSku = "PRO-14",
    limit      = 5,
};

// Generic body + generic response — the SDK serializes and deserializes for you.
var results = await client.CallAsync<object, Ticket[]>(
    endpoint:          "similar-tickets",
    body:              request,
    cancellationToken: CancellationToken.None);

foreach (var t in results) Console.WriteLine(t.Subject);

Available overloads:

Signature When to use
CallAsync<TBody, TResponse>(endpoint, body, ct) Typed request + typed response.
CallAsync<TResponse>(endpoint, ct) No body, typed response.
CallAsync<TBody>(endpoint, body, ct) Typed request, fire-and-wait (no response value).
CallAsync(endpoint, ct) No body, no response.

What the SDK does for you under the hood:

  • Targets POST /api/cce/token/run/{endpoint} (an alias of /api/endpoints/token/run/{endpoint}).
  • Attaches the Authorization: Bearer … header on every request.
  • Loops on 202 with a 1-second delay, re-sending the same body each time, until the server returns 200 or throws.
  • Throws HttpRequestException on 500/non-success. Cancel via CancellationToken.

The SDK uses the "re-send the body" poll mode. For most workloads that's fine; if your request bodies are large and your endpoint is slow, the raw-HttpClient example below shows the X-MSK-ENDPOINT-KEY optimization that skips resending the body on every poll.

No built-in deadline

The SDK loops indefinitely while the server returns 202. If you need a deadline, pass a CancellationTokenSource with CancelAfter(...):

using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2));
var results = await client.CallAsync<object, Ticket[]>("similar-tickets", request, cts.Token);

C# — HttpClient directly (no SDK)

Use this when you can't add the Curiosity.Library dependency, or you want full control of timeouts, headers, and retries.

using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading;
using System.Threading.Tasks;

static async Task<T> CallEndpointAsync<T>(
    HttpClient http,
    string     baseUrl,
    string     endpointName,
    object     body,
    string     token,
    CancellationToken ct)
{
    var url      = $"{baseUrl.TrimEnd('/')}/api/endpoints/token/run/{endpointName}";
    var retry    = 0;
    string? cacheKey = null;     // captured from X-MSK-ENDPOINT-KEY on the first 202

    while (true)
    {
        using var req = new HttpRequestMessage(HttpMethod.Post, url);
        req.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
        req.Headers.TryAddWithoutValidation("X-MSK-RETRY", retry.ToString()); // optional

        if (cacheKey is null)
        {
            // First request — send the body. The server hashes it into a cache key.
            req.Content = JsonContent.Create(body);
        }
        else
        {
            // Subsequent polls — echo the cache key, omit the body.
            req.Headers.TryAddWithoutValidation("X-MSK-ENDPOINT-KEY", cacheKey);
        }

        var response = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct);

        if (response.StatusCode == HttpStatusCode.Accepted)
        {
            // Capture the cache key so subsequent polls can skip the body.
            if (response.Headers.TryGetValues("X-MSK-ENDPOINT-KEY", out var keys))
                cacheKey = keys.FirstOrDefault();

            if (response.Headers.TryGetValues("CalculationProgress", out var progress))
                Console.WriteLine($"progress: {string.Join(" ", progress)}");

            await Task.Delay(TimeSpan.FromSeconds(1), ct);
            retry++;
            continue;
        }

        response.EnsureSuccessStatusCode();           // throws on 4xx/5xx
        return (await response.Content.ReadFromJsonAsync<T>(cancellationToken: ct))!;
    }
}

Call it the same way:

using var http = new HttpClient();
var results = await CallEndpointAsync<Ticket[]>(
    http, "https://workspace.example.com", "similar-tickets",
    new { query = "screen flicker", limit = 5 },
    Environment.GetEnvironmentVariable("WORKSPACE_ENDPOINT_TOKEN")!,
    CancellationToken.None);

Other languages

The protocol is the same in every language: POST, branch on the status code, sleep on 202, poll. The C# and Python samples below demonstrate the X-MSK-ENDPOINT-KEY optimization (capture the key on 202, echo it back on subsequent polls, drop the body). The other languages re-POST the body each time to keep the samples short; applying the same optimization is a two-line change.

import os, time, requests

BASE   = "https://workspace.example.com"
TOKEN  = os.environ["WORKSPACE_ENDPOINT_TOKEN"]

def call_endpoint(name: str, body: dict, timeout_s: float = 120) -> dict:
url       = f"{BASE}/api/endpoints/token/run/{name}"
base_hdrs = {
"Authorization": f"Bearer {TOKEN}",
"Accept":        "application/json",
}

deadline  = time.monotonic() + timeout_s
retry     = 0
cache_key = None  # captured from X-MSK-ENDPOINT-KEY on the first 202

while True:
if time.monotonic() > deadline:
raise TimeoutError(f"endpoint {name} did not finish in {timeout_s}s")

headers = dict(base_hdrs)
headers["X-MSK-RETRY"] = str(retry)   # optional

if cache_key is None:
# First request — send the body.
headers["Content-Type"] = "application/json"
r = requests.post(url, headers=headers, json=body, timeout=30)
else:
# Subsequent polls — echo the cache key, omit the body.
headers["X-MSK-ENDPOINT-KEY"] = cache_key
r = requests.post(url, headers=headers, timeout=30)

if r.status_code == 202:
cache_key = r.headers.get("X-MSK-ENDPOINT-KEY") or cache_key
progress  = r.headers.get("CalculationProgress")
if progress:
print(f"progress: {progress}")
time.sleep(1)
retry += 1
continue

r.raise_for_status()
return r.json()

results = call_endpoint("similar-tickets", {
"query":      "screen flicker after firmware update",
"productSku": "PRO-14",
"limit":      5,
})

Implementation checklist

  • Use the same URL and Authorization header on every poll. Either keep sending the same body, or capture X-MSK-ENDPOINT-KEY from the first 202 and echo it back on subsequent polls with an empty body — pick one and stick with it for the duration of the loop. Changing the body without sending the cache key starts a new task.
  • Sleep ~1 second between polls. Going faster does not speed up the work and will hit rate limits.
  • Keep polling. Stopping for >5 seconds may pause the task; >1 minute cancels it. This is also how you cancel: drop the loop.
  • Wrap the loop in a caller-side deadline. The protocol itself has no upper bound.
  • Treat 500 as final and surface the response body — it is the endpoint's exception message.
  • Log the X-MSK-ENDPOINT-KEY alongside the traceId when filing support requests.
  • If the endpoint sets CalculationProgress, surface it to the user — for long-running jobs this is the only feedback they get.
© 2026 Curiosity. All rights reserved.
Powered by Neko