Tovább a tartalomhoz
ÁrakAlkalmazás

TypeScript kliens

Ez az oldal egy teljes, függőség nélküli TypeScript klienst ad, amit bemásolhatsz a saját projektedbe. Node 20 vagy újabb kell hozzá: a fetch és a crypto beépített, tehát nincs szükség külső csomagra.

A kód azt a hibakezelést és újrapróbálást valósítja meg, amit az API referencia leír. A folyamat üzleti magyarázata az Integrációs példa oldalon van.

Az API válaszainak alakja egységes, ezért néhány típus lefedi az egészet. A nem kitöltött mezők kimaradnak a válaszból, ezért szinte minden mező opcionális.

/** Firestore időbélyeg a válaszokban (a kérésekben ISO 8601 stringet küldesz). */
export interface ApiTimestamp {
_seconds: number;
_nanoseconds: number;
}
export const toDate = (t: ApiTimestamp): Date => new Date(t._seconds * 1000);
export interface ApiError {
code: string;
message: string;
status: number;
requestId: string;
details?: Record<string, unknown>;
}
export interface Pagination {
hasMore: boolean;
limit: number;
nextCursor?: string;
}
export interface Customer {
id: string;
name: string;
phone?: string;
email?: string;
company?: string;
taxNumber?: string;
address?: { zip?: string; city?: string; street?: string; country?: string; fullText?: string };
addresses?: Array<{ id: string; label?: string; isPrimary?: boolean; zip?: string; city?: string; street?: string }>;
externalIds?: Record<string, string>;
customFields?: Record<string, string>;
createdAt: ApiTimestamp;
}
export interface WorksheetItem {
id: string;
type: 'material' | 'labor' | 'travel' | 'other' | 'section';
description: string;
quantity: number;
unit: string;
/** Bruttó egységár. Kitöltetlenül a mező hiányzik. */
unitPrice?: number;
totalPrice?: number;
vatRate: 0 | 5 | 18 | 27;
note?: string;
/** Több eszközös szerviz munkalapon a `devices` tömb elemének azonosítója. */
deviceLocalId?: string;
isWarranty?: boolean;
}
export interface Worksheet {
id: string;
worksheetNumber: string;
status: string;
customerId: string;
customerName?: string;
title?: string;
worksheetMode: 'field' | 'service';
isSurvey: boolean;
subtotal?: number;
vatAmount?: number;
total?: number;
items?: WorksheetItem[];
assignments?: Array<{ role: string; userId: string; userName?: string }>;
/** Csak a létrehozás válaszában: jelzi, ha új ügyfél is létrejött. */
customerCreated?: boolean;
createdAt: ApiTimestamp;
url: string;
}

A status szándékosan string, nem szűk unió: a fiók egyedi státuszokat is felvehet (custom_ előtaggal), és egy szigorú unió a következő egyedi státusznál törne. A lehetséges értékek definícióit a GET /v1/worksheet-statuses adja.

Egyetlen osztály, ami elvégzi a hitelesítést, a hibák felismerését, az idempotencia-kulcs kezelését és a 429 utáni újrapróbálást.

import { randomUUID } from 'node:crypto';
export class OkosMunkalapError extends Error {
constructor(
readonly code: string,
readonly status: number,
readonly requestId: string,
message: string,
readonly details?: Record<string, unknown>,
) {
super(message);
this.name = 'OkosMunkalapError';
}
/** Van értelme újrapróbálni ugyanazzal a kéréssel? */
get retryable(): boolean {
return this.status === 429 || this.status >= 500;
}
}
interface RequestOptions {
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
body?: unknown;
/** Létrehozó hívásokhoz. Megadás nélkül a kliens generál egyet. */
idempotencyKey?: string;
query?: Record<string, string | number | undefined>;
}
export class OkosMunkalapClient {
constructor(
private readonly apiKey: string,
private readonly baseUrl = 'https://api.okosmunkalap.hu',
/** Hány újrapróbálás fér bele egy hívásba (429 és 5xx esetén). */
private readonly maxRetries = 3,
) {}
/** Egyetlen erőforrás (a válasz `data` mezője). */
async request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { data } = await this.send<T>(path, options);
return data;
}
/** Listavégpont: a `data` mellett a `pagination` is kell a lapozáshoz. */
async requestPage<T>(
path: string,
options: RequestOptions = {},
): Promise<{ data: T[]; pagination: Pagination }> {
const { data, pagination } = await this.send<T[]>(path, options);
return { data, pagination: pagination ?? { hasMore: false, limit: 0 } };
}
private async send<T>(
path: string,
options: RequestOptions,
): Promise<{ data: T; pagination?: Pagination }> {
const method = options.method ?? 'GET';
const url = new URL(this.baseUrl + path);
for (const [key, value] of Object.entries(options.query ?? {})) {
if (value !== undefined) url.searchParams.set(key, String(value));
}
const headers: Record<string, string> = { Authorization: `Bearer ${this.apiKey}` };
if (options.body !== undefined) headers['Content-Type'] = 'application/json';
// Az idempotencia-kulcs a hívás elején dől el, és az ÖSSZES újrapróbáláson ugyanaz marad.
// Ez a lényege: ettől lesz a megismételt kérés ugyanaz a művelet, nem egy második.
if (method === 'POST') {
headers['Idempotency-Key'] = options.idempotencyKey ?? randomUUID();
}
const body = options.body === undefined ? undefined : JSON.stringify(options.body);
let lastError: OkosMunkalapError | undefined;
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
const response = await fetch(url, { method, headers, body });
const text = await response.text();
const payload = text ? (JSON.parse(text) as Record<string, unknown>) : {};
if (response.ok) return payload as { data: T; pagination?: Pagination };
const error = payload.error as ApiError | undefined;
lastError = new OkosMunkalapError(
error?.code ?? 'UNKNOWN',
response.status,
error?.requestId ?? '',
error?.message ?? `HTTP ${response.status}`,
error?.details,
);
if (!lastError.retryable || attempt === this.maxRetries) throw lastError;
// A Retry-After a szerver kérése, ezért az elsőbbséget élvez. Ha hiányzik (5xx),
// exponenciális visszalépés jitterrel, hogy több kliens ne egyszerre próbálkozzon újra.
const retryAfter = Number(response.headers.get('retry-after'));
const waitMs =
Number.isFinite(retryAfter) && retryAfter > 0
? retryAfter * 1000
: 2 ** attempt * 500 + Math.floor(Math.random() * 250);
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
throw lastError;
}
}

Két részlet, ami könnyen elromlik, ezért a kód külön kezeli:

  • Az idempotencia-kulcs az újrapróbálásokon át ugyanaz marad. Ha minden próbálkozás új kulcsot kapna, egy időtúllépés utáni ismétlés második rekordot hozna létre. Éppen ez ellen véd a fejléc.
  • A Retry-After megelőzi a saját visszalépést. A 429 válasz megmondja, hány másodperc múlva nyílik a következő ablak; ennél korábban újrapróbálni felesleges forgalom.

A createIfMissing ág akkor is megtalálja a meglévő ügyfelet, ha az már létezik, tehát nem kell előtte keresned.

const client = new OkosMunkalapClient(process.env.OKOSMUNKALAP_API_KEY!);
const worksheet = await client.request<Worksheet>('/v1/worksheets', {
method: 'POST',
body: {
customer: {
lookupExternalId: 'CUST-9001',
createIfMissing: {
name: 'Példa Kft.',
phone: '+36 30 123 4567',
address: { fullText: '1051 Budapest, Fő utca 1.' },
},
},
title: 'Hibajavítás',
siteContactName: 'Kovács Anna',
siteContactPhone: '+36 70 222 3344',
},
});
console.log(worksheet.worksheetNumber, worksheet.customerCreated ? '(új ügyfél)' : '(meglévő ügyfél)');

A hibákat a code mezőre ágazva kezeld, ne az üzenet szövegére. A teljes katalógus a Hibakódok oldalon van.

try {
await client.request<Worksheet>('/v1/worksheets', { method: 'POST', body: payload });
} catch (error) {
if (!(error instanceof OkosMunkalapError)) throw error;
switch (error.code) {
case 'EXTERNAL_ID_AMBIGUOUS':
// A külső azonosító több élő ügyfélre illeszkedik: a rendszer szándékosan nem választ.
// Jelezd a kezelőnek, hogy az azonosító nem egyértelmű, és ne hozz létre semmit.
await notifyDispatcher(error.requestId);
break;
case 'MISSING_PERMISSION':
// A kulcs jogosultságai hiányosak; ez kód-hiba, nem futásidejű állapot.
throw error;
default:
// A requestId a kulcs a hibakereséshez, ezért mindig naplózd.
logger.error({ code: error.code, requestId: error.requestId }, error.message);
throw error;
}
}

A listavégpontok kurzor alapúak. A lapozás közben ne változtasd a szűrőket: a kurzor hozzájuk van kötve, és eltérés esetén 400 jön INVALID_CURSOR kóddal.

async function* iterateWorksheets(
client: OkosMunkalapClient,
filters: Record<string, string | number | undefined> = {},
): AsyncGenerator<Worksheet> {
let cursor: string | undefined;
do {
const page = await client.requestPage<Worksheet>('/v1/worksheets', {
query: { ...filters, limit: 100, starting_after: cursor },
});
for (const worksheet of page.data) yield worksheet;
cursor = page.pagination.hasMore ? page.pagination.nextCursor : undefined;
} while (cursor);
}
for await (const worksheet of iterateWorksheets(client, { status: 'completed' })) {
console.log(worksheet.worksheetNumber, worksheet.total);
}

A Webhookok oldalon szereplő JavaScript és PHP minta TypeScript változata, Express környezetben.

A legfontosabb: az aláírás a nyers kéréstörzsön készül. Ha a keretrendszered már objektummá alakította a JSON-t, és te azt szerializálod újra, az ellenőrzés akkor is elbukik, ha az aláírás helyes. Ezért a webhook útvonalon nyers törzset kell kérni.

import express from 'express';
import crypto from 'node:crypto';
const TOLERANCE_SEC = 300; // ötperces replay-ablak
export function verifySignature(
rawBody: Buffer,
signatureHeader: string | undefined,
secret: string,
toleranceSec = TOLERANCE_SEC,
): boolean {
if (!signatureHeader) return false;
const parts = new Map(
signatureHeader.split(',').map((part) => {
const [key, ...rest] = part.trim().split('=');
return [key, rest.join('=')] as const;
}),
);
const timestamp = Number(parts.get('t'));
const received = parts.get('v1');
if (!Number.isInteger(timestamp) || !received) return false;
// Replay-ablak: a túl régi és a jövőbeli időbélyeg is elutasítandó.
if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > toleranceSec) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody.toString('utf8')}`)
.digest('hex');
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(received, 'utf8');
// A timingSafeEqual eltérő hosszra kivételt dob, ezért előbb a hosszt hasonlítjuk.
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
const app = express();
app.post(
'/webhooks/okosmunkalap',
express.raw({ type: 'application/json' }), // NYERS törzs, nem express.json()
(req, res) => {
const valid = verifySignature(
req.body as Buffer,
req.header('X-OkosMunkalap-Signature'),
process.env.OKOSMUNKALAP_WEBHOOK_SECRET!,
);
if (!valid) return res.status(400).send('invalid signature');
const event = JSON.parse((req.body as Buffer).toString('utf8')) as {
id: string;
type: string;
webhookVersion: string;
createdAt: string;
data: Worksheet | Customer;
};
// Válaszolj gyorsan, és a feldolgozást tedd háttérbe: a küldő időtúllépésre újrapróbál.
res.status(200).send('ok');
// Deduplikáció: ugyanaz az esemény újrapróbáláskor ugyanazt az `id`-t hozza.
void handleEventOnce(event.id, event.type, event.data);
},
);

Az eseményeket mindig deduplikáld az id mező alapján (ez az X-OkosMunkalap-Delivery fejléccel azonos). Ugyanaz az esemény hálózati hiba után újra megérkezhet, és az azonosító ilyenkor változatlan marad.

Ha nem kézzel írt klienst szeretnél, az OpenAPI leírásból generálhatsz egyet:

Terminál
npx @openapitools/openapi-generator-cli generate \
-i https://docs.okosmunkalap.hu/openapi.yaml \
-g typescript-fetch \
-o ./src/okosmunkalap

A generált kliens a mezőneveket és a típusokat pontosan követi, viszont a fenti újrapróbálást és idempotencia-kezelést neked kell köré tenned: a generátor ezeket nem ismeri.