# Any language

> Send events to ClickClacks from any language over plain HTTP: one POST with JSON. Complete examples with safe retries in curl, Python, Go, Ruby and PHP.

- Canonical URL: https://clickclacks.io/docs/http
- Section: SDKs
- Last updated: 2026-09-26

There’s no SDK to wait for. Sending events is one HTTP POST with a JSON body, so any language with an HTTP client works today.

On PHP or Laravel, the [PHP SDK](https://clickclacks.io/docs/php.md), and on Node.js the [Node SDK](https://clickclacks.io/docs/node.md), handle batching and retries for you. Everywhere else, this page has what you need.

## The whole contract {#contract}

- `POST https://app.clickclacks.io/api/v1/batch`
- `Authorization: Bearer $CLICKCLACKS_SERVER_KEY`, a [server key](https://clickclacks.io/docs/server-side.md#keys), from your server only.
- `Content-Type: application/json` and a body of `{ "items": [...] }`, up to 500 items and 1 MiB.
- Give every item an `insert_id`, and reuse it when you retry.
- Retry network errors, `408`, `429` and `5xx` with backoff, honouring `Retry-After`. Don’t retry other `4xx`.

The item fields, limits and every error code are in [Send events](https://clickclacks.io/docs/api.md).

## Send an event {#send-one}

The shortest version, fine for a script or a first test:

**curl**

```bash title="terminal"
curl https://app.clickclacks.io/api/v1/batch \
  -H "Authorization: Bearer $CLICKCLACKS_SERVER_KEY" \
  -H "Content-Type: application/json" \
  -d '{"items":[{"event":"Invoice paid","distinct_id":"user_8412","insert_id":"inv_2291","properties":{"amount_cents":4900}}]}'
```

**Python**

```python title="send.py"
import os, uuid, requests

requests.post(
    "https://app.clickclacks.io/api/v1/batch",
    headers={"Authorization": f"Bearer {os.environ['CLICKCLACKS_SERVER_KEY']}"},
    json={"items": [{"event": "Invoice paid", "distinct_id": "user_8412",
                     "insert_id": uuid.uuid4().hex,
                     "properties": {"amount_cents": 4900}}]},
    timeout=10,
).raise_for_status()
```

**Go**

```go title="send.go"
body, _ := json.Marshal(map[string]any{"items": []map[string]any{{
    "event": "Invoice paid", "distinct_id": "user_8412",
    "insert_id": uuid.NewString(),
    "properties": map[string]any{"amount_cents": 4900},
}}})
req, _ := http.NewRequest("POST", "https://app.clickclacks.io/api/v1/batch", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+os.Getenv("CLICKCLACKS_SERVER_KEY"))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req) // retry on 429/5xx, honouring Retry-After
```

**Ruby**

```ruby title="send.rb"
require "json"
require "net/http"
require "securerandom"

Net::HTTP.post(
  URI("https://app.clickclacks.io/api/v1/batch"),
  JSON.generate(items: [{ event: "Invoice paid", distinct_id: "user_8412",
                          insert_id: SecureRandom.hex(16),
                          properties: { amount_cents: 4900 } }]),
  "Authorization" => "Bearer #{ENV.fetch("CLICKCLACKS_SERVER_KEY")}",
  "Content-Type" => "application/json",
)
```

**PHP**

```php title="send.php"
<?php
$ch = curl_init('https://app.clickclacks.io/api/v1/batch');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 10,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer ' . getenv('CLICKCLACKS_SERVER_KEY'),
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode(['items' => [[
        'event' => 'Invoice paid',
        'distinct_id' => 'user_8412',
        'insert_id' => bin2hex(random_bytes(16)),
        'properties' => ['amount_cents' => 4900],
    ]]]),
]);
$response = curl_exec($ch); // retry on 429/5xx, honouring Retry-After
```

> **PHP and empty properties**
>
> `json_encode([])` produces a JSON array, which the API refuses as `invalid_properties`. Leave `properties` out, or send `(object) []`.

## With safe retries {#retries}

For production. Each loop builds the body once, so every retry carries the same `insert_id`s and nothing is counted twice. It backs off exponentially with jitter (up to 30 seconds), honours `Retry-After` (up to 5 minutes), gives up after 6 retries, and never retries a request the API refused on its merits.

**Python**

```python title="send.py"
import os, random, time, uuid, requests

def send(items, attempts=6):
    body = {"items": items}  # each item already has its insert_id
    for attempt in range(attempts + 1):
        try:
            resp = requests.post(
                "https://app.clickclacks.io/api/v1/batch",
                headers={"Authorization": f"Bearer {os.environ['CLICKCLACKS_SERVER_KEY']}"},
                json=body, timeout=10,
            )
        except requests.RequestException:
            resp = None
        if resp is not None and resp.status_code < 500 and resp.status_code not in (408, 429):
            resp.raise_for_status()   # other 4xx: fix the request, don't retry
            return resp.json()        # 202: check resp.json()["errors"] for refused items
        if attempt == attempts:
            raise RuntimeError("ClickClacks: gave up after retries")
        retry_after = resp.headers.get("Retry-After") if resp is not None else None
        delay = float(retry_after) if retry_after else random.uniform(0, min(30, 0.5 * 2 ** attempt))
        time.sleep(min(delay, 300))

send([{"event": "Invoice paid", "distinct_id": "user_8412",
       "insert_id": uuid.uuid4().hex, "properties": {"amount_cents": 4900}}])
```

**Go**

```go title="send.go"
func send(body []byte) error {
	client := &http.Client{Timeout: 10 * time.Second}
	for attempt := 0; ; attempt++ {
		req, _ := http.NewRequest("POST", "https://app.clickclacks.io/api/v1/batch", bytes.NewReader(body))
		req.Header.Set("Authorization", "Bearer "+os.Getenv("CLICKCLACKS_SERVER_KEY"))
		req.Header.Set("Content-Type", "application/json")
		resp, err := client.Do(req)
		retry := err != nil
		var wait time.Duration
		if err == nil {
			resp.Body.Close() // for a 202, decode it first and check "errors"
			switch {
			case resp.StatusCode < 300:
				return nil
			case resp.StatusCode == 408 || resp.StatusCode == 429 || resp.StatusCode >= 500:
				retry = true
				if s, e := strconv.Atoi(resp.Header.Get("Retry-After")); e == nil {
					wait = time.Duration(s) * time.Second
				}
			default:
				return fmt.Errorf("clickclacks: HTTP %d, not retrying", resp.StatusCode)
			}
		}
		if !retry || attempt == 6 {
			return fmt.Errorf("clickclacks: gave up after %d attempts", attempt+1)
		}
		if wait == 0 {
			ceiling := math.Min(30, 0.5*math.Pow(2, float64(attempt)))
			wait = time.Duration(rand.Float64() * ceiling * float64(time.Second))
		}
		time.Sleep(min(wait, 5*time.Minute))
	}
}
```

**Ruby**

```ruby title="send.rb"
require "json"
require "net/http"
require "securerandom"

BATCH_URI = URI("https://app.clickclacks.io/api/v1/batch")

def send_batch(items, attempts: 6)
  body = JSON.generate(items: items) # each item already has its insert_id
  (0..attempts).each do |attempt|
    response = begin
      Net::HTTP.post(BATCH_URI, body,
                     "Authorization" => "Bearer #{ENV.fetch("CLICKCLACKS_SERVER_KEY")}",
                     "Content-Type" => "application/json")
    rescue StandardError
      nil # network error: retry
    end
    if response
      status = response.code.to_i
      return JSON.parse(response.body) if status < 300 # 202: check "errors" for refused items
      raise "ClickClacks: HTTP #{status}, not retrying" unless status == 408 || status == 429 || status >= 500
    end
    raise "ClickClacks: gave up after retries" if attempt == attempts
    retry_after = response && response["Retry-After"]
    delay = retry_after ? retry_after.to_f : rand * [30, 0.5 * 2**attempt].min
    sleep [delay, 300].min
  end
end

send_batch([{ event: "Invoice paid", distinct_id: "user_8412",
              insert_id: SecureRandom.hex(16), properties: { amount_cents: 4900 } }])
```

**PHP**

```php title="send.php"
<?php
function clickclacks_send(array $items, int $attempts = 6): array
{
    $body = json_encode(['items' => $items]); // each item already has its insert_id
    for ($attempt = 0; ; $attempt++) {
        $ch = curl_init('https://app.clickclacks.io/api/v1/batch');
        curl_setopt_array($ch, [
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => $body,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HEADER => true,
            CURLOPT_TIMEOUT => 10,
            CURLOPT_HTTPHEADER => [
                'Authorization: Bearer ' . getenv('CLICKCLACKS_SERVER_KEY'),
                'Content-Type: application/json',
            ],
        ]);
        $response = curl_exec($ch);
        $status = $response === false ? 0 : curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        $headers = $response === false ? '' : substr($response, 0, curl_getinfo($ch, CURLINFO_HEADER_SIZE));
        $payload = $response === false ? '' : substr($response, strlen($headers));
        curl_close($ch);

        if ($status >= 200 && $status < 300) {
            return json_decode($payload, true); // 202: check "errors" for refused items
        }
        $retryable = $status === 0 || $status === 408 || $status === 429 || $status >= 500;
        if (!$retryable) {
            throw new RuntimeException("ClickClacks: HTTP $status, not retrying");
        }
        if ($attempt === $attempts) {
            throw new RuntimeException('ClickClacks: gave up after retries');
        }
        $delay = preg_match('/^Retry-After:\s*(\d+)/mi', $headers, $match)
            ? (int) $match[1]
            : mt_rand() / mt_getrandmax() * min(30, 0.5 * 2 ** $attempt);
        usleep((int) (min($delay, 300) * 1000000));
    }
}

clickclacks_send([[
    'event' => 'Invoice paid',
    'distinct_id' => 'user_8412',
    'insert_id' => bin2hex(random_bytes(16)),
    'properties' => ['amount_cents' => 4900],
]]);
```

A `202` can still list refused items in `errors`. Log them with their `index` and `code`; retrying them unchanged won’t help.

## Try it without storing {#dry-run}

Add `?validate=true` and the API answers `200` with each item exactly as it would be stored, plus every error, and stores nothing. Handy while you write the integration, and in CI. `?strict=true` refuses the whole batch if any item is invalid.

```bash title="terminal"
curl "https://app.clickclacks.io/api/v1/batch?validate=true" \
  -H "Authorization: Bearer $CLICKCLACKS_SERVER_KEY" \
  -H "Content-Type: application/json" \
  -d '{"items":[{"event":"Invoice paid","distinct_id":"user_8412","properties":{"amount_cents":4900}}]}'
```

## Large batches {#gzip}

Batch up to 500 items per request: the per-project rate limits count events in blocks of 100, so single-event requests run out far sooner. Compress big bodies with gzip; the 1 MiB limit applies after decompression.

```bash title="terminal"
gzip -c batch.json > batch.json.gz

curl https://app.clickclacks.io/api/v1/batch \
  -H "Authorization: Bearer $CLICKCLACKS_SERVER_KEY" \
  -H "Content-Type: application/json" \
  -H "Content-Encoding: gzip" \
  --data-binary @batch.json.gz
```

## Next steps {#next}

- [Send events](https://clickclacks.io/docs/api.md): fields, limits and errors.
- [Identity from the server](https://clickclacks.io/docs/server-identity.md): which person each event lands on.
- [Limits](https://clickclacks.io/docs/limits.md): every limit in one table.
