SECU Dealer · API v1
Kehittäjädokumentaatio
Suomeksi lyhyesti. Rajapinnalla tilaat puhelutarkastuksen, tarkastuksia ja ostopalvelun suoraan omasta järjestelmästäsi ja saat valmiin auton tiedot (kunto, havainnot, kuvat, leimat, raportin linkki ja myyntiteksti) takaisin rakenteisena JSON-datana. Tämä dokumentaatio on englanniksi, koska koodi ja kentät ovat englanniksi. Tunnukset haet osoitteessa /app/. Hinnat ovat alv 0 ja maksu tulee jälkikäteen laskulla.
Koneluettava kuvaus: openapi.yaml. Jos dokumentaation ja OpenAPI-kuvauksen välillä on ristiriita, OpenAPI-kuvaus on ensisijainen.
Quickstart
- Apply for access. After approval, sign in to the portal (email link, no password) and create an API key under API keys. Create a test key first.
- Place a call-check order for one car (listing URL is enough):
curl https://secudeals.com/api/dealer/v1/orders \
-H "Authorization: Bearer $SECU_API_KEY" \
-H "Idempotency-Key: STOCK-1042-callcheck" \
-H "Content-Type: application/json" \
-d '{
"service": "call_check",
"car": { "listing_url": "https://www.example.com/ilmoitus/12345678" },
"options": { "reserve_if_good": true, "max_price": 18500, "auto_continue": "inspection" },
"external_reference": "STOCK-1042"
}'
- Receive progress either by webhook or by polling
GET /orders/{id}orGET /events. - When the order is
completed, fetch the car feed withGET /cars/{car_id}and write it into your inventory system.
Base URL and format
| Base URL | https://secudeals.com/api/dealer/v1 |
| Format | JSON requests and responses, UTF-8. Send Content-Type: application/json on POST. |
| Timestamps | ISO 8601 in UTC with Z, e.g. 2026-10-01T09:30:00Z. Convert to local time (Europe/Helsinki) in your UI. |
| Money | Decimal numbers in EUR. All prices are excl. VAT ("vat": "excluded"); invoices to Finnish companies add 25.5 % VAT, EU companies are invoiced under reverse charge. |
| Pagination | List endpoints take ?limit= (1–100, default 25) and ?starting_after= (id of the last object you received). A list is {"object":"list","data":[…],"has_more":bool,"next_cursor":"…"|null}; pass next_cursor as starting_after to get the next page. |
| Tracing | Every response has X-Request-Id and Dealer-API-Version. Quote the request id when you contact support. You may send your own X-Request-Id. |
| CORS | Allowed (Bearer auth, no cookies). Do not call the API from a browser with a live key, because the key would be visible to anyone. |
Authentication
Send your key as a Bearer token. Keys look like alk_live_… or alk_test_….
Authorization: Bearer alk_live_xxxxxxxxxxxxxxxxxxxxxxxx
Keys are shown in full only once, when created. Create one key per integration, and revoke a key in the portal if it leaks. A dealer sees only its own data. GET /pricing is public and needs no key.
Test mode
A alk_test_… key creates orders with "mode": "test" and ids prefixed DT-. Test orders are not worked by our staff, send no messages, accrue no billable charges, and progress automatically to completed with a sample report. Use test mode to build and verify your integration, including webhooks. Live and test data are separate.
Errors
Errors use HTTP status codes and a consistent body:
{
"error": {
"code": "invalid_car",
"message": "car: anna listing_url, vin tai make+model",
"details": { }
}
}
code is stable and machine-readable; message is for humans and may be in Finnish. details is optional.
| HTTP | code | Meaning |
|---|---|---|
| 400 | invalid_json, invalid_idempotency_key | Malformed body or header. |
| 401 | missing_api_key, invalid_api_key | No key, or the key is invalid or revoked. |
| 403 | account_inactive, dealer_inactive | The dealer account is not active. |
| 404 | not_found | Unknown endpoint, or the object is not yours (you never learn whether it exists). |
| 409 | invalid_state, not_ready, decision_conflict, transport_conflict, idempotency_in_progress | Action not possible in the current state, report not ready yet, a different decision/transport choice was already made, or an identical request is still running. |
| 413 | payload_too_large | Request body over 1 MB. |
| 422 | invalid_car, invalid_service, invalid_options, invalid_parameter, invalid_decision, invalid_price, missing_details, idempotency_key_reused | Validation failed (invalid_*), or the Idempotency-Key was used with a different request. |
| 429 | rate_limited | Too many requests. See Retry-After. |
| 501 | not_available | The feature is not available (yet). |
| 5xx | internal_error, upstream_error | Our side. Retry with the same Idempotency-Key; quote X-Request-Id if it persists. |
Codes may be added over time; handle unknown codes by their HTTP status. The full list per endpoint is in openapi.yaml.
Rate limits and idempotency
Rate limit: 60 requests per minute per key. Responses carry X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (unix seconds); over the limit you get 429 with Retry-After. Keep polling modest; webhooks are cheaper.
Idempotency: send an Idempotency-Key header (1–90 printable ASCII characters) on POST requests. Retrying the same request with the same key returns the original response (header Idempotent-Replay: true) and never creates or bills a second order; the same key with a different body returns 422 idempotency_key_reused. Use a key derived from your own record, e.g. stock number plus service. Keys are remembered for 48 hours. Always send one when you retry after a timeout.
Orders
One order = one service for one car. Create it with POST /orders.
Services
| service | Name | Price (excl. VAT) |
|---|---|---|
call_check | History check + call to the seller; can reserve the car in the call | 39 € |
inspection_basic | Basic inspection | 229 € |
inspection | Kuntotarkastus™ | 279 € |
inspection_full | Laaja tarkastus™ | 389 € |
purchase | Ostopalvelu™ (purchase service) | Dealer price shown with your API key (GET /pricing) and in the portal |
Prices above are the current list prices. Your account may have its own prices; the source of truth is GET /pricing and the price object of each order. Success fee: a share of the negotiated discount (asking price minus final price), billed only when a lower price is achieved; your rate is returned by GET /pricing and GET /me with your API key. Prices are VAT 0 %; Finnish companies are invoiced with 25.5 % VAT added (EU companies: reverse charge). Special vehicle types (camper, sports, classic, heavy, machine) add 150 € to inspections.
Request body
{
"service": "call_check", // required, see table above
"car": { // at least one of: listing_url, vin, make+model
"listing_url": "https://…",
"vin": "WVWZZZ3CZKE123456",
"make": "Volkswagen", "model": "Passat", "variant": "2.0 TDI",
"year": 2019, "km": 142000, "price": 18900, "fuel": "diesel",
"country": "DE", "city": "Hamburg", "zip": "20095",
"seller_name": "…", "seller_phone": "…", "seller_type": "dealer", // optional
"notes": "…"
},
"options": {
"reserve_if_good": true, // call_check only: reserve in the call if the car looks good
"max_price": 18500, // ceiling for the reservation
"reserve_days": 3, // 1–14
"auto_continue": "inspection", // call_check only: continue to this service if verdict is good
"vehicle_type": "car", // car|camper|sports|classic|heavy|machine
"showroom_photos": false,
"sales_text": true, // generate a sales text (default true)
"tone": "…", "notes": "…"
},
"external_reference": "STOCK-1042" // your own id, echoed back
}
Order object
{
"id": "DO-2610-K7TQ", "object": "order", "mode": "live", "livemode": true,
"car_id": "9f2c…",
"service": "call_check", "status": "in_progress", "phase": "seller_call",
"external_reference": "STOCK-1042",
"car": { … }, "options": { … }, "parent_order_id": null,
"verdict": null, "summary": null,
"price": { "currency": "EUR", "vat": "excluded", "net": 39.0, "lines": [ … ] },
"created_at": "2026-10-01T09:30:00Z", "updated_at": "…", "completed_at": null
}
When available the order also contains reservation (reserved_at, valid_until, agreed_price), deal (asking_price, final_price), report (url, view_url, verification_code), highlights[] and sales_text. Purchase-chain fields: decision, decision_options[] (while a decision is awaited), documents[], transport, delivery and, on GET /orders/{id}, process (see Decisions, contract & delivery).
Status, phase and verdict
| Field | Values |
|---|---|
status | received → in_progress → completed. Side branches: on_hold (credit limit reached; waits for release), cancelled. |
phase | Free-form progress label, e.g. queued, history_check, seller_call, inspection_scheduled, inspection_done, report_ready. Show it to people; do not build logic on a fixed list. |
verdict | recommended, reserved, caution, not_recommended, unreachable (seller not reached). Set when the order completes. |
Listing: GET /orders filters: status (comma-separated), service, verdict, car_id, external_reference, created_after, created_before (ISO 8601).
Cancelling: POST /orders/{id}/cancel. Free before work has started; once the order is in_progress, the service is billed.
Chained orders: with auto_continue, a successful call_check creates the follow-up order automatically (parent_order_id points to the first one; an order.continued event is emitted).
Decisions, purchase contract and delivery
Suomeksi: prosessi etenee viidessä vaiheessa. Liike tilaa puhelutarkastuksen (halutessaan «varaa heti jos puhelussa vaikuttaa hyvältä»), saa historiaraportin ja puhelun tiedot ja päättää: Etene tarkastukseen tai Hylkää. Tarkastusraportin jälkeen päätös on Saa ostaa ja tinkiä tai Hylkää. Kaupan jälkeen liike allekirjoittaa kauppakirjan sähköisesti, näkee tingityn hinnan ja valitsee kuljetuksen (tilaus meiltä tai hoitaa itse). Toimituksen jälkeen tulee palvelulasku. Sama toimii portaalissa napeilla ja rajapinnasta alla kuvatuilla kutsuilla.
- Order a
call_check(optionallyoptions.reserve_if_good). - Decide when the order completes (
order.completed; the order hasdecision_options: ["proceed","reject"]):POST /orders/{id}/decisionwith{"decision":"proceed","service":"inspection"}or{"decision":"reject"}.proceedcreates the inspection order (parent_order_id, eventorder.continued). - Skip the inspection: after the history call you can also send
{"decision":"buy","max_price":18500}to go straight to purchase and negotiation (no condition report, no SECU condition stamp). - Decide again after the inspection report:
{"decision":"buy","target_price":17500,"max_price":18500,"note":"…"}creates thepurchaseorder (Ostopalvelu™: our staff negotiate; the success fee applies to the discount) or{"decision":"reject"}.buyis allowed unless the verdict isnot_recommended/unreachable(cautionis allowed). - Contract and transport. The purchase order gets
documents[]:{type:"purchase_contract", status:"awaiting_signature", sign_url, signed_at}(eventdocument.created); opensign_urlto e-sign with mouse or touch, after which the status becomessigned(eventdocument.signed; read back automatically within minutes). The negotiated price is indeal.final_price. Choose transport:GET /orders/{id}/transport-quote(our truck freight from the car's location to your address, VAT 0), thenPOST /orders/{id}/transportwith{"mode":"abco"}(we transport),"bahnexpress_own"(your own BahnExpress credentials) or"own"(you handle it). Eventorder.transport_chosen; the order'stransportholds{mode, at, freight_order_id?}. - Delivery. When the car is delivered the order gets
delivery: {at, source}and the eventorder.delivered; the service invoice follows (seeGET /invoices).
Idempotent decisions. Repeating the same decision returns the same result and never creates a second order; a different decision afterwards is 409 decision_conflict. Decisions cannot be changed. Choosing transport is idempotent likewise (409 transport_conflict if a freight order is already placed; cancel it first). A decision is also recorded when a follow-up already exists from auto_continue.
Progress. GET /orders/{id} returns process: five steps (order, call_decision, inspection, deal, delivery) with state done | current | action | stopped | todo; action means the ball is in your court (a decision, a signature or the transport choice). process.pending names the decision awaited and the order it concerns.
curl -X POST https://secudeals.com/api/dealer/v1/orders/DO-2610-K7TQ/decision \
-H "Authorization: Bearer $ALK_KEY" -H "Content-Type: application/json" \
-d '{"decision":"proceed","service":"inspection"}'
# → 200 { …order with "decision":{"decision":"proceed","at":"…","next_order_id":"DO-2610-M3XA"}, "next_order": { …new inspection order… } }
sample: true, signing is simulated about two minutes after completion); choosing own transport is marked delivered after about two minutes, a transport order from us after about five. No real documents, messages or transports are created. Steer the outcome of the follow-up order by writing sandbox:recommended|caution|… in the decision note.Cars: the data feed
car_id identifies a car in your archive: all orders for the same car (matched by VIN, otherwise by listing id) share it. GET /cars lists your archive; GET /cars/{car_id} returns the complete structured feed intended for your inventory or website system:
- vehicle data and equipment, condition and findings, paint thickness, tyres, history,
- photos as URLs (
GET /cars/{car_id}/photos/{n}), stamps, report link and sales text.
The feed never contains the seller's or any customer's personal data, internal costs or inspector identities. Photo URLs accept ?w= (width 64–2400) and ?stamp=kunto|historia (apply a SECU stamp to the image); ?showroom=1 requests the showroom-background version where it has been ordered. Fetch images with your key (Bearer) from your server and store them with your listing.
GET /cars accepts vin, q (free text), external_reference and verdict. The feed can be incomplete while an order is still running; fetch it when order.completed arrives. In test mode the data is a sample.
Exact field names and types are defined in openapi.yaml.
Stamps and sales text
GET /cars/{car_id}/stamps returns the stamps issued for the car (verification code, kinds, verify URL, image URLs); ?download=kunto|historia&w=600 returns the PNG itself. A stamp is issued only when the data behind it has been verified, never when the verdict is not_recommended or unreachable. Verification: https://secu.fi/t/<code>. Stamp images: https://secudeals.com/stamp/<code>-kunto.png and …-historia.png.
You may use a stamp only for the car it was issued for, you must not edit it, and you must stop using it if it is revoked. See the terms, section 7. A revoked stamp is no longer rendered and the verification page says so.
GET /cars/{car_id}/sales-text returns the generated Finnish sales text (text, title, bullets, link, generated_at); POST to the same path regenerates it (optional tone; 20 per hour). In test mode the sample text is returned. The text is written from verified data only. Review it before publishing.
Freight
POST /freight/quote and POST /freight/orders price and order transport. Providers: abco (our truck freight, excl. VAT, price and estimated duration) and, if you have stored your own credentials in the portal, bahnexpress_own (a quote and order placed with your BahnExpress account). Stored credentials are encrypted and are never returned by the API. Track with GET /freight/orders/{id} or the freight.status event. Parameters are in openapi.yaml.
Events
Every state change creates an event. Read them via webhook or poll: GET /events?after=evt_123 returns newer events oldest-first (store the last id you processed); starting_after browses backwards. Filter with type (exact, or order.*) and order_id. Event envelope:
{
"id": "evt_1042", "object": "event", "api_version": "2026-10-01",
"type": "order.completed", "created_at": "2026-10-01T11:02:00Z",
"livemode": true, "order_id": "DO-2610-K7TQ", "car_id": "9f2c…",
"data": { …order object… }
}
| type | When |
|---|---|
order.created | Order accepted by the API |
order.on_hold | Credit limit reached; order waits for release |
order.accepted | Work has started (in_progress) |
order.updated | phase changed |
order.reserved | Car reserved in the call |
report.ready | Report link available |
order.deal_recorded | Final price recorded (success fee calculated) |
stamps.issued | SECU stamps issued for the car |
adtext.ready | Sales text generated |
order.completed | Order finished; fetch the car feed |
order.continued | Follow-up order created (auto_continue or your decision) |
order.cancelled | Order cancelled |
freight.status | Freight order status changed |
order.decision | You decided (proceed, buy or reject); data.decision holds the record |
document.created | A document (purchase contract) is ready for your signature |
document.signed | The purchase contract was e-signed |
order.transport_chosen | Transport chosen (data.transport) |
order.delivered | The car was delivered (data.delivery) |
Webhooks
Register an HTTPS endpoint with POST /webhooks (or in the portal). The signing secret (whsec_…) is shown once in the response. Subscribe to ["*"], to exact types, or to prefixes like "order.*". The format follows the Standard Webhooks convention.
Headers
webhook-id: evt_1042 // same id on every retry: de-duplicate on it
webhook-timestamp: 1759312920 // unix seconds
webhook-signature: v1,K5oZfzN… // base64 HMAC-SHA256
Verifying the signature
The signed content is {webhook-id}.{webhook-timestamp}.{raw body}. The key is the base64-decoded part of the secret after whsec_. Reject requests whose timestamp differs more than 5 minutes from your clock. Compare in constant time, and use the raw request body, not a re-encoded one.
import crypto from 'node:crypto';
export function verify(rawBody, headers, secret) {
const id = headers['webhook-id'], ts = headers['webhook-timestamp'];
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const expected = crypto.createHmac('sha256', key).update(`${id}.${ts}.${rawBody}`).digest('base64');
return headers['webhook-signature'].split(' ').some(s => {
const [v, sig] = s.split(',');
return v === 'v1' && sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
});
}<?php
function secu_verify(string $raw, array $h, string $secret): bool {
$id = $h['webhook-id'] ?? ''; $ts = (int)($h['webhook-timestamp'] ?? 0);
if (abs(time() - $ts) > 300) return false;
$key = base64_decode(preg_replace('/^whsec_/', '', $secret));
$expected = base64_encode(hash_hmac('sha256', "$id.$ts.$raw", $key, true));
foreach (explode(' ', $h['webhook-signature'] ?? '') as $part) {
[$v, $sig] = array_pad(explode(',', $part, 2), 2, '');
if ($v === 'v1' && hash_equals($expected, $sig)) return true;
}
return false;
}
$raw = file_get_contents('php://input'); // raw bodyimport base64, hashlib, hmac, time
def verify(raw: bytes, headers: dict, secret: str) -> bool:
mid, ts = headers["webhook-id"], headers["webhook-timestamp"]
if abs(time.time() - int(ts)) > 300:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
msg = f"{mid}.{ts}.".encode() + raw
expected = base64.b64encode(hmac.new(key, msg, hashlib.sha256).digest()).decode()
return any(p.startswith("v1,") and hmac.compare_digest(p[3:], expected)
for p in headers["webhook-signature"].split(" "))Delivery and retries
- Respond with any
2xxwithin a few seconds. Do your heavy work asynchronously. - Delivery is at least once; events may arrive more than once and out of order. De-duplicate on
webhook-idand usecreated_atfor ordering. - Failed deliveries are retried up to 10 times with growing delays (30 s, 2 min, 10 min, 30 min, 1 h, 3 h, 6 h, 12 h, 24 h). After that the delivery is marked dead and stays available for redelivery.
- An endpoint that returns
410 Gone, or whose deliveries die 5 times in a row, is disabled automatically. - Endpoints must be public HTTPS addresses; private networks are refused. Up to 10 endpoints per mode.
- Send a test event to your endpoint with
POST /webhooks/{id}/test(or from the portal) before going live. You can redeliver an earlier delivery withPOST /webhooks/{id}/deliveries/{delivery_id}/redeliver. PollingGET /eventsis always available as a safety net.
Endpoint reference
Summary only. Parameters, schemas and error codes: openapi.yaml.
| Endpoint | Description |
|---|---|
GET/pricing | Current price list (public, no key needed; with a key you see your own prices). |
GET/me | Your account, credit limit and exposure, key mode and rate limit. |
POST/orders | Create an order. Supports Idempotency-Key. |
GET/orders | List orders (limit, starting_after). |
GET/orders/{id} | Retrieve an order. |
POST/orders/{id}/cancel | Cancel an order. |
POST/orders/{id}/decision | Decide: proceed (inspection), buy (purchase — after the inspection, or directly after the history call without an inspection) or reject. Idempotent. |
GET/orders/{id}/transport-quote | Direct transport quote for a purchase order. |
POST/orders/{id}/transport | Choose transport: abco, bahnexpress_own or own. |
GET/orders/{id}/report | Report for a completed order (409 not_ready before that). |
GET/cars | Your archive of checked cars. |
GET/cars/{car_id} | Complete data feed for a car. |
GET/cars/{car_id}/photos/{n} | Photo n; options w, stamp, showroom. |
GET/cars/{car_id}/stamps | Stamps issued for the car. |
GET/cars/{car_id}/sales-text | Generated sales text. |
POST/cars/{car_id}/sales-text | Regenerate the sales text. |
POST/freight/quote | Freight quote. |
POST/freight/orders | Order freight. |
GET/freight/orders/{id} | Freight order status. |
GET/webhooks | List webhook endpoints. |
GET/webhooks/{id} | Retrieve an endpoint with recent deliveries. |
POST/webhooks | Create a webhook endpoint (secret shown once). |
DELETE/webhooks/{id} | Delete a webhook endpoint. |
POST/webhooks/{id}/test | Send a test event now. |
POST/webhooks/{id}/deliveries/{delivery_id}/redeliver | Redeliver a past delivery. |
GET/events | Event feed for polling. |
GET/invoices | Your invoices, plus the not-yet-invoiced total. |
Changelog
| Version | Notes |
|---|---|
2026-10-01 | First release. Decision endpoint (/orders/{id}/decision), purchase contract documents, transport choice (/transport-quote, /transport), process and the events order.decision, order.transport_chosen, order.delivered, document.created, document.signed. |
Questions: info@auto-saksasta.fi · 010 320 8080.