REST API for developers

Sync and process club data automatically – with n8n, Make, Zapier or your own programs.

Open API referenceOpenAPI 3.1 (JSON)

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 under https://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

  1. In the admin area, go to Admin area › Integrations › API keys and click New key (administrators only).
  2. 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.
  3. Copy the key svs_live_… right away – it is shown only once. shootingscore.app stores only a hash.
  4. 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: &lt;key name&gt;”.
  • 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.

ScopeContent
people:readbasic person data (name, numbers, membership, contribution group, year of birth), contribution groups, teams
people.contact:readalso contact data (e-mail, phone, address, date of birth) – every call is logged
people:writecreate and update people (incl. both read scopes)
events:readevents and disciplines
results:readseries, single shots, rankings
results:writecreate series, enter results and shots
cash:readcash registers and bookings (without the cashier's name)
work-duty:read / work-duty:writework duty overview and hours / enter hours
range:readrange attendance, supervision shifts, ranges
firearms:readfirearm log per person – specially protected
documents:readdocuments 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

MethodPathScopePurpose
GET/me–connection test
GET/contribution-groupspeople:readcontribution groups
GET/teamspeople:readteams
GET/peoplepeople:readpeople (filters, pagination, sync)
GET/people/{id}people:readone person (ETag)
POST/peoplepeople:writecreate a person
PATCH/people/{id}people:writeupdate a person (merge patch)
GET/eventsevents:readevents
GET/events/{id}events:readevent with disciplines
GET/events/{id}/disciplinesevents:readdisciplines
GET/events/{id}/rankingresults:readranking
GET/seriesresults:readseries (filters, sync)
GET/series/{id}?shots=trueresults:readseries with single shots
POST/seriesresults:writecreate a series, optionally with a cash booking
PUT/series/{id}/resultresults:writeset the result
PUT/series/{id}/shotsresults:writereplace single shots (server computes)
GET/cash/registerscash:readcash registers
GET/cash/bookingscash:readbookings in a period
GET/work-duty/overviewwork-duty:readtarget/actual with compensation amounts
GET/work-duty/hourswork-duty:readwork hours
POST/work-duty/hourswork-duty:writeenter hours
GET/attendancerange:readattendance
GET/range-supervision/shiftsrange:readsupervision shifts
GET/rangesrange:readshooting ranges
GET/people/{id}/firearmsfirearms:readfirearm log of a person
GET/people/{id}/documentsdocuments:readdocuments of a person
GET/documents/{id}/filedocuments:readfile of a document

All paths start with /api/v1. Unknown query parameters are rejected with 400 validation – typos show up immediately.

Glossary

FieldMeaning
scorepoints of the series (with one decimal for tenth scoring)
innerTensinner tens
teiler“Teiler” – distance of the shot from the centre (smaller is better), kept as the technical term
scoringscoring: bestSeries (best series counts), sum, time, teiler, luckyShot, honoraryTarget, eagleCombined
targetGrouptarget group: all, male, female, youth
number (series)running number of the series per person and discipline
hiddenresults hidden until the award ceremony (king's shoot) – values are null then
startNumberstart number in the club
federationNumberfederation/shooter passport number
honoraryGuesthonorary guest (may shoot on the honorary target)
contributionGroupmembership fee group (target work hours and range duties)
workSessionannounced work session
x, yhit 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…" }
  • limit 1–200 (default 50). Fetch the next page via nextPage (also as Link: <…>; rel="next") until hasMore is false.
  • 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:

  1. First run: GET /api/v1/people?changedSince=0 – all records, page by page.
  2. Store syncState from the last page (e.g. in n8n static data).
  3. 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"}}'
  • firstName and lastName are required; gender is male (default) or female.
  • externalId is your ID from the source system – use it to find the person again (GET /people?externalId=campai-4711).
  • If the membership number or externalId is already taken, you get 409 conflict with existingId – then update with PATCH.
  • PATCH /people/{id} is a JSON Merge Patch (RFC 7396): only fields you send change, null clears a field.
  • Unknown fields are rejected (422 validation with fields).

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); paymentMethod is cash (default), card or transfer. Without cashRegisterId there is no booking.
  • PUT /series/{id}/result sets score, innerTens, time, teiler (missing fields = empty) with the same checks as the admin area.
  • PUT /series/{id}/shots replaces 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

LimitValue
requests per key600 per minute (of which 120 writes)
requests per club1,200 per minute
concurrent requests per key4

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, times HH:mm, amounts and points as numbers.
  • null is 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:read the 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:

  1. Fetch members from the source system (HTTP Request or a dedicated node).
  2. For each member POST /api/v1/people with externalId = ID in the source system and Idempotency-Key = the same ID.
  3. Answer 409 conflict → take existingId and update with PATCH /api/v1/people/{existingId} (in n8n: *Settings › On Error › Continue (using error output)*).

Billing work duty at year end

  1. *Schedule Trigger* once a year (e.g. 15 January).
  2. GET /api/v1/work-duty/overview?year=2026 – per member missingHours, missingShifts and amount.
  3. Filter rows with amount > 0 and create an invoice or direct debit in your accounting or club management software.

Recipe: Make

  1. Module HTTP › Make a request: URL https://shootingscore.app/api/v1/people, method GET, header Authorization = Bearer svs_live_…, *Parse response* = yes.
  2. Pagination: a Repeater or a loop “while hasMore” – the next request goes to nextPage from the previous response.
  3. For triggers: run a query with sort=-changed&limit=20 regularly and use a Data store (key id) 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

  1. Trigger Webhooks by Zapier › Retrieve Poll: URL https://shootingscore.app/api/v1/series?sort=-changed&limit=50, header Authorization: Bearer svs_live_…, *Key* = data.
  2. Zapier detects new items by id. To also trigger on changed results, build your own key id + "-" + updatedAt in a *Code by Zapier* step.
  3. Any action (Google Sheets, Slack, e-mail …). Write calls via Webhooks by Zapier › Custom Request with Content-Type: application/json and Idempotency-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/Sunset headers).
  • Changes are listed in the changelog under “API”.