Events
API & other languages
Send product events, insights and expenses from any backend: the Python SDK, or plain HTTP — the API is described by an OpenAPI 3.1 spec.
OpenAPI spec
The write API (POST /api/v1/events, POST /api/v1/insights and POST / PATCH /api/v1/expenses) is described by an OpenAPI 3.1 document, generated from the same validation the server runs, so it can't drift from what the endpoints accept:
https://metricneed.com/api/v1/openapi.json
Import it in Postman, Insomnia or Bruno, browse it in any OpenAPI viewer, or generate a client with openapi-generator. Authentication is a bearer mne_… key from Settings → Events API — server-side only; the field reference and error codes are on Events & insights.
Python SDK
pip install metricneed
import os
from metricneed import MetricNeed
mn = MetricNeed(api_key=os.environ["METRICNEED_API_KEY"], base_url=os.environ.get("METRICNEED_URL", "https://metricneed.com"))
mn.track("signups", "New signup", user_id="usr_42", tags={"plan": "pro"})
mn.insight.increment("Signups today")
mn.shutdown() # flush before a short-lived process exits Same API as the Node.js SDK, in Python: track() never raises and sends in a background thread; track_sync() waits for the answer and raises a MetricNeedError; batch=True queues events and sends them in batches; retries cover network errors, 429 and 5xx. Zero dependencies, Python 3.9+. Call shutdown() before a serverless function or a script exits.
Plain HTTP
No SDK needed: one JSON POST per event (or up to 100 in { "events": [...] }). Each snippet reads METRICNEED_API_KEY, and METRICNEED_URL for a self-hosted or local instance.
Go
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
)
func main() {
base := os.Getenv("METRICNEED_URL")
if base == "" {
base = "https://metricneed.com"
}
body, _ := json.Marshal(map[string]any{
"channel": "signups", "event": "New signup", "user_id": "usr_42",
"tags": map[string]string{"plan": "pro"},
})
req, _ := http.NewRequest(http.MethodPost, base+"/api/v1/events", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+os.Getenv("METRICNEED_API_KEY"))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
out, _ := io.ReadAll(res.Body)
fmt.Println(res.StatusCode, string(out))
}Ruby
require "json"
require "net/http"
require "uri"
uri = URI("#{ENV.fetch("METRICNEED_URL", "https://metricneed.com")}/api/v1/events")
request = Net::HTTP::Post.new(uri, "Authorization" => "Bearer #{ENV.fetch("METRICNEED_API_KEY")}", "Content-Type" => "application/json")
request.body = { channel: "signups", event: "New signup", user_id: "usr_42", tags: { plan: "pro" } }.to_json
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
puts response.code, response.bodyPHP
<?php
$ch = curl_init((getenv('METRICNEED_URL') ?: 'https://metricneed.com') . '/api/v1/events');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('METRICNEED_API_KEY'), 'Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode(['channel' => 'signups', 'event' => 'New signup', 'user_id' => 'usr_42', 'tags' => ['plan' => 'pro']]),
]);
$body = curl_exec($ch);
echo curl_getinfo($ch, CURLINFO_HTTP_CODE), "\n", $body, "\n";Swift (server-side)
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
let env = ProcessInfo.processInfo.environment
var request = URLRequest(url: URL(string: "\(env["METRICNEED_URL"] ?? "https://metricneed.com")/api/v1/events")!)
request.httpMethod = "POST"
request.setValue("Bearer \(env["METRICNEED_API_KEY"]!)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONSerialization.data(withJSONObject: [
"channel": "signups", "event": "New signup", "user_id": "usr_42", "tags": ["plan": "pro"]
])
let (data, response) = try await URLSession.shared.data(for: request)
print((response as! HTTPURLResponse).statusCode, String(decoding: data, as: UTF8.self))Swift on a server (Vapor, Hummingbird) — never from an iOS app: the key would ship in the app bundle.
Insights
Set a gauge with value, or add to it atomically with increment:
curl -X POST https://metricneed.com/api/v1/insights \
-H "Authorization: Bearer $METRICNEED_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "Signups today", "increment": 1 }'Expenses
Add one of your expenses — a recurring subscription with intervalUnit + intervalCount, or a one-off without them — from a script or when a vendor invoice arrives. Same fields and rules as the Expenses form: amounts in major units, dates as YYYY-MM-DD, startedAt is the first charge. The Free plan's limit of 5 expenses applies (402 expense_limit).
curl -X POST https://metricneed.com/api/v1/expenses \
-H "Authorization: Bearer $METRICNEED_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "Vercel Pro", "vendor": "vercel", "category": "hosting", "amount": 20, "currency": "USD", "intervalUnit": "month", "intervalCount": 1, "startedAt": "2026-09-01" }' The response carries the expense's id. Keep it to edit the expense later — only the fields you send change; endedAt cancels a subscription, null reactivates it:
curl -X PATCH https://metricneed.com/api/v1/expenses/$EXPENSE_ID \
-H "Authorization: Bearer $METRICNEED_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "endedAt": "2026-09-30" }'Keys stay write-only: they can't list or delete expenses — do that on the Expenses page.