Overview
With the REST API v1, clubs connect shootingscore.app to their own programs and automation services such as n8n, Make or Zapier: sync members from the club management software, publish new results on the club website or in a messenger group, take over series from your own scoring system, or bill work duty at the end of the year.
-
Base URL:
https://shootingscore.app/api/v1(same data underhttps://svscore.de/api/v1, with German error texts there) - Reference: all endpoints, fields and examples at /api/v1/reference, machine-readable as OpenAPI 3.1
- Plan: included in the Club package. With an Event pass the API is read-only while the pass is valid.
- Format: JSON (UTF-8), English camelCase field names, errors according to RFC 9457
The API is meant for server-to-server calls. Never put the key into website JavaScript or apps – for rankings on your club website, the public results page is enough.
Quick start
- In the admin area, go to Admin area › Integrations › API keys and click New key (administrators only).
- Enter a name (e.g. “n8n members”), pick a template or individual scopes and a validity. If two-factor authentication is active, enter the code from your authenticator app.
-
Copy the key
svs_live_…right away – it is shown only once. shootingscore.app stores only a hash. - Test the connection:
curl -H "Authorization: Bearer svs_live_abcdefghij_…" https://shootingscore.app/api/v1/me
$h = @{ Authorization = "Bearer $env:SVSCORE_KEY" }
Invoke-RestMethod -Uri https://shootingscore.app/api/v1/me -Headers $h
import os, requests
r = requests.get("https://shootingscore.app/api/v1/me", headers={"Authorization": f"Bearer {os.environ['SVSCORE_KEY']}"})
r.raise_for_status()
print(r.json()["club"]["name"], r.json()["key"]["effectiveScopes"])
/me returns club, key (key ID, scopes, expiry), limits and plan – ideal as a connection test in n8n, Make or Zapier.
Keys and security
-
Format:
svs_live_<key ID>_<secret><checksum>. The key ID (10 characters) is public and shown in the admin area and log; the checksum detects typos. -
Always send the key in the
Authorization: Bearer …header – never as a URL parameter. - Validity 30, 90 or 365 days or unlimited. 14 days before expiry all administrators receive an e-mail.
- Renew creates a new key with the same scopes; the old one stays valid until you revoke it – so you can switch without interruption. Revoking takes effect immediately.
- A key acts like a club administrator, but only within its scopes. Changes show “API: <key name>”.
-
An API key only opens
/api/v1– it never reaches the admin area itself (password login).
Scopes
A scope looks like area:read or area:write; write includes read of the same area.
| Scope | Content |
|---|---|
people:read | basic person data (name, numbers, membership, contribution group, year of birth), contribution groups, teams |
people.contact:read | also contact data (e-mail, phone, address, date of birth) – every call is logged |
people:write | create and update people (incl. both read scopes) |
events:read | events and disciplines |
results:read | series, single shots, rankings |
results:write | create series, enter results and shots |
cash:read | cash registers and bookings (without the cashier's name) |
work-duty:read / work-duty:write | work duty overview and hours / enter hours |
range:read | range attendance, supervision shifts, ranges |
firearms:read | firearm log per person – specially protected |
documents:read | documents per person (firearms licence, certificates …), files one at a time – specially protected |
Specially protected: firearms:read and documents:read can only be selected if the creating admin has two-factor authentication active, and only with code confirmation. They are never preselected in templates, every access is logged individually, and document files are only available one at a time. The API never shows more than the admin area shows an administrator (firearm log and documents of this club's people).
Endpoints
| Method | Path | Scope | Purpose |
|---|---|---|---|
| GET | /me | – | connection test |
| GET | /contribution-groups | people:read | contribution groups |
| GET | /teams | people:read | teams |
| GET | /people | people:read | people (filters, pagination, sync) |
| GET | /people/{id} | people:read | one person (ETag) |
| POST | /people | people:write | create a person |
| PATCH | /people/{id} | people:write | update a person (merge patch) |
| GET | /events | events:read | events |
| GET | /events/{id} | events:read | event with disciplines |
| GET | /events/{id}/disciplines | events:read | disciplines |
| GET | /events/{id}/ranking | results:read | ranking |
| GET | /series | results:read | series (filters, sync) |
| GET | /series/{id}?shots=true | results:read | series with single shots |
| POST | /series | results:write | create a series, optionally with a cash booking |
| PUT | /series/{id}/result | results:write | set the result |
| PUT | /series/{id}/shots | results:write | replace single shots (server computes) |
| GET | /cash/registers | cash:read | cash registers |
| GET | /cash/bookings | cash:read | bookings in a period |
| GET | /work-duty/overview | work-duty:read | target/actual with compensation amounts |
| GET | /work-duty/hours | work-duty:read | work hours |
| POST | /work-duty/hours | work-duty:write | enter hours |
| GET | /attendance | range:read | attendance |
| GET | /range-supervision/shifts | range:read | supervision shifts |
| GET | /ranges | range:read | shooting ranges |
| GET | /people/{id}/firearms | firearms:read | firearm log of a person |
| GET | /people/{id}/documents | documents:read | documents of a person |
| GET | /documents/{id}/file | documents:read | file of a document |
All paths start with /api/v1. Unknown query parameters are rejected with 400 validation – typos show up immediately.
Glossary
| Field | Meaning |
|---|---|
score | points of the series (with one decimal for tenth scoring) |
innerTens | inner tens |
teiler | “Teiler” – distance of the shot from the centre (smaller is better), kept as the technical term |
scoring | scoring: bestSeries (best series counts), sum, time, teiler, luckyShot, honoraryTarget, eagleCombined |
targetGroup | target group: all, male, female, youth |
number (series) | running number of the series per person and discipline |
hidden | results hidden until the award ceremony (king's shoot) – values are null then |
startNumber | start number in the club |
federationNumber | federation/shooter passport number |
honoraryGuest | honorary guest (may shoot on the honorary target) |
contributionGroup | membership fee group (target work hours and range duties) |
workSession | announced work session |
x, y | hit position in 1/100 mm from the target centre (x to the right, y upwards) |
Pagination
Lists answer with
{ "data": [ … ], "hasMore": true, "cursor": "aWQ6NDI.k3…", "nextPage": "https://shootingscore.app/api/v1/people?limit=50&cursor=aWQ6NDI.k3…" }
-
limit1–200 (default 50). Fetch the next page vianextPage(also asLink: <…>; rel="next") untilhasMoreisfalse. - The cursor is signed and only valid for this list and this key.
Sync: fetch only changes
People, series and work hours support change sync via changedSince:
-
First run:
GET /api/v1/people?changedSince=0– all records, page by page. -
Store
syncStatefrom the last page (e.g. in n8n static data). -
Next run:
GET /api/v1/people?changedSince=<syncState>– only what changed since, including deletions:
{ "id": 17, "deleted": true, "replacedBy": 12 }
replacedBy is set for merged people (the data now lives with person 12). Sync captures every change – including changes from the admin area, the app and bulk updates.
For simple polling triggers (Zapier, Make) use sort=-changed: most recently changed records first.
Writing
Idempotency-Key
With POST, send the Idempotency-Key header – ideally the ID from the source system (e.g. campai-4711). If your workflow repeats the request (timeout, restart), shootingscore.app returns the stored response within 24 hours (Idempotent-Replayed: true) instead of creating a second record. Same key with different content → 422 idempotency_conflict.
Creating and updating people
curl -X POST https://shootingscore.app/api/v1/people \
-H "Authorization: Bearer $SVSCORE_KEY" -H "Content-Type: application/json" -H "Idempotency-Key: campai-4711" \
-d '{"firstName":"Anna","lastName":"Adler","gender":"female","isMember":true,"membershipNumber":"100",
"externalId":"campai-4711","contact":{"email":"anna@example.org","birthDate":"1990-05-06"}}'
-
firstNameandlastNameare required;genderismale(default) orfemale. -
externalIdis your ID from the source system – use it to find the person again (GET /people?externalId=campai-4711). -
If the membership number or
externalIdis already taken, you get409 conflictwithexistingId– then update withPATCH. -
PATCH /people/{id}is a JSON Merge Patch (RFC 7396): only fields you send change,nullclears a field. -
Unknown fields are rejected (
422 validationwithfields).
ETag and If-Match
GET /people/{id}, /series/{id} and /events/{id} return an ETag. Send it as If-Match when changing – if someone else changed the record in the meantime, you get 412 version_mismatch instead of a silent overwrite. With If-None-Match, an unchanged record answers 304.
Series, results and cash desk
POST /api/v1/series
{ "personId": 12, "disciplineId": 34, "result": { "score": 98.7, "innerTens": 3 }, "cashRegisterId": 1, "paymentMethod": "card" }
- Only active disciplines of non-archived competitions (internal events use entries).
-
With
cashRegisterId, shootingscore.app books the series purchase into that cash register – at the same price as at the cash desk (first series start price, further ones rebuy price);paymentMethodiscash(default),cardortransfer. WithoutcashRegisterIdthere is no booking. -
PUT /series/{id}/resultsetsscore,innerTens,time,teiler(missing fields = empty) with the same checks as the admin area. -
PUT /series/{id}/shotsreplaces all single shots; the server computes score, inner tens and teiler as for a scoring machine.
Work hours
POST /api/v1/work-duty/hours
{ "personId": 12, "date": "2026-10-17", "from": "08:00", "until": "10:30", "activity": "Mowing the lawn" }
Default is approved (like entry in the admin area); with "status": "requested" the entry goes to the admins for review.
Errors
Errors come as application/problem+json (RFC 9457):
{ "type": "https://shootingscore.app/developers#scope-missing", "title": "Scope missing", "status": 403,
"detail": "The API key lacks the scope “people:read”.", "code": "scope_missing", "requiredScope": "people:read", "traceId": "00-…" }
Evaluate code (stable, English); detail is for humans and follows the domain or Accept-Language: en.
not_authenticated
401 – no Authorization header. Response with WWW-Authenticate: Bearer.
key_invalid
401 – key unknown, typo or wrong format. Many failed attempts from one address are blocked temporarily.
key_expired
401 – validity expired. Renew in the admin area.
key_revoked
401 – the key has been revoked.
scope_missing
403 – the key lacks the scope requiredScope.
plan
403 – not included in the plan (Basic) or a write call with an Event pass.
club_suspended
403 – club suspended or not yet activated.
not_found
404 – the record does not exist (or belongs to another club). For deleted people with deleted and possibly replacedBy.
conflict
409 – e.g. membership number or external ID already taken; existingId names the existing record.
idempotency_conflict
409 – the same request is still running; 422 – Idempotency-Key already used for different content.
version_mismatch
412 – If-Match no longer matches; reload and change again.
validation
400 – invalid parameters/JSON; 422 – invalid content. fields names the affected fields.
too_many_requests
429 – limit reached; retry after Retry-After seconds.
internal
500 – unexpected error; we are notified automatically. Please quote the traceId when contacting us.
Limits
| Limit | Value |
|---|---|
| requests per key | 600 per minute (of which 120 writes) |
| requests per club | 1,200 per minute |
| concurrent requests per key | 4 |
Every response contains RateLimit-Policy and RateLimit (remaining requests, seconds until reset).
Times and formats
-
Timestamps as ISO 8601 with offset in German time, e.g.
2026-07-01T12:00:00+02:00. Input without offset counts as German time. -
Dates
yyyy-MM-dd, timesHH:mm, amounts and points as numbers. -
nullis always included. New fields may be added at any time – please ignore unknown fields.
Data protection
- The club is the controller and chooses connected services itself. Services such as Zapier or Make process the retrieved data (often in the USA) – the club needs its own data processing agreement with the service. Self-hosted n8n is the most privacy-friendly option.
-
Select only the scopes you need. Without
people.contact:readthe API returns only the year of birth and no contact data (contact: null). - The API never returns hidden results (before the award ceremony).
- Under Admin area › Integrations › API log, admins see all write calls, reading of contact data and every access to firearm and document data – field names only, never contents (90 days).
- No CORS: calls from the browser are not intended.
Recipe: n8n
Credentials: in n8n, create a Header Auth credential – name Authorization, value Bearer svs_live_….
New results to Telegram
Every 10 minutes, fetch new or changed series and post results. The sync state is kept in the workflow's static data.
{
"name": "shootingscore: new results → Telegram",
"nodes": [
{ "name": "Every 10 minutes", "type": "n8n-nodes-base.scheduleTrigger", "typeVersion": 1.2, "position": [0, 0],
"parameters": { "rule": { "interval": [{ "field": "minutes", "minutesInterval": 10 }] } } },
{ "name": "Read state", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [220, 0],
"parameters": { "jsCode": "const s = $getWorkflowStaticData('global');\nreturn [{ json: { state: s.state ?? '0' } }];" } },
{ "name": "Fetch series", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [440, 0],
"parameters": {
"url": "https://shootingscore.app/api/v1/series", "authentication": "genericCredentialType", "genericAuthType": "httpHeaderAuth",
"sendQuery": true, "queryParameters": { "parameters": [ { "name": "changedSince", "value": "={{ $json.state }}" }, { "name": "limit", "value": "200" } ] },
"options": { "pagination": { "pagination": { "paginationMode": "responseContainsNextURL", "nextURL": "={{ $response.body.nextPage }}",
"paginationCompleteWhen": "other", "completeExpression": "={{ !$response.body.hasMore }}" } } }
} },
{ "name": "Store state + filter", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [660, 0],
"parameters": { "jsCode": "const pages = $input.all().map(i => i.json);\nconst s = $getWorkflowStaticData('global');\ns.state = pages[pages.length - 1].syncState;\nreturn pages.flatMap(p => p.data).filter(x => !x.deleted && x.recorded && !x.hidden).map(x => ({ json: x }));" } },
{ "name": "Telegram", "type": "n8n-nodes-base.telegram", "typeVersion": 1.2, "position": [880, 0],
"parameters": { "chatId": "-100123456789", "text": "=New result: series {{ $json.id }} – {{ $json.score }} points" } }
],
"connections": {
"Every 10 minutes": { "main": [[{ "node": "Read state", "type": "main", "index": 0 }]] },
"Read state": { "main": [[{ "node": "Fetch series", "type": "main", "index": 0 }]] },
"Fetch series": { "main": [[{ "node": "Store state + filter", "type": "main", "index": 0 }]] },
"Store state + filter": { "main": [[{ "node": "Telegram", "type": "main", "index": 0 }]] }
}
}
After importing, select the Header Auth credentials in Fetch series and adjust the chat ID. For names and disciplines, additionally query GET /people/{id} or GET /events/{id} (with a cache).
Importing members from the club management software
For Campai and easyVerein there is a ready-made sync under *Admin area › Integrations*. For other systems:
- Fetch members from the source system (HTTP Request or a dedicated node).
-
For each member
POST /api/v1/peoplewithexternalId= ID in the source system andIdempotency-Key= the same ID. -
Answer
409 conflict→ takeexistingIdand update withPATCH /api/v1/people/{existingId}(in n8n: *Settings › On Error › Continue (using error output)*).
Billing work duty at year end
- *Schedule Trigger* once a year (e.g. 15 January).
-
GET /api/v1/work-duty/overview?year=2026– per membermissingHours,missingShiftsandamount. -
Filter rows with
amount > 0and create an invoice or direct debit in your accounting or club management software.
Recipe: Make
-
Module HTTP › Make a request: URL
https://shootingscore.app/api/v1/people, method GET, headerAuthorization=Bearer svs_live_…, *Parse response* = yes. -
Pagination: a Repeater or a loop “while
hasMore” – the next request goes tonextPagefrom the previous response. -
For triggers: run a query with
sort=-changed&limit=20regularly and use a Data store (keyid) to pass on only new/changed records.
Module excerpt (blueprint):
{ "module": "http:ActionSendData", "version": 3,
"mapper": { "url": "https://shootingscore.app/api/v1/series", "method": "get", "parseResponse": true,
"headers": [ { "name": "Authorization", "value": "Bearer {{svscoreKey}}" } ],
"qs": [ { "name": "sort", "value": "-changed" }, { "name": "limit", "value": "20" } ] } }
Recipe: Zapier
-
Trigger Webhooks by Zapier › Retrieve Poll: URL
https://shootingscore.app/api/v1/series?sort=-changed&limit=50, headerAuthorization: Bearer svs_live_…, *Key* =data. -
Zapier detects new items by
id. To also trigger on changed results, build your own keyid + "-" + updatedAtin a *Code by Zapier* step. -
Any action (Google Sheets, Slack, e-mail …). Write calls via Webhooks by Zapier › Custom Request with
Content-Type: application/jsonandIdempotency-Key.
Webhooks (instant notification instead of polling) are planned for version 1.1.
Versioning
-
The version is part of the path (
/api/v1). New fields, endpoints, filters and enum values are added without a new version – clients ignore unknown items. -
Breaking changes only in a new version; v1 then stays available for at least 12 months (with
Deprecation/Sunsetheaders). - Changes are listed in the changelog under “API”.