API v1 Articole PSK
API-ul îți permite să plasezi comenzi de articole pe publicații partenere, să urmărești statusul, să încarci conținut, să gestionezi wallet-ul și proiectele, și să fii notificat automat prin webhook când articolul este publicat.
Base URL: https://articole.psk.ro/api/v1
Toate răspunsurile sunt JSON. Toate sumele monetare sunt în EUR net (fără TVA), unde nu se specifică altfel. Datele de tip moment în timp apar atât ca timestamp Unix (întreg) cât și ca string ISO 8601 UTC.
HEADER Autentificare
Toate request-urile către API trebuie să conțină header-ul:
Authorization: Bearer psk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Cheia se generează din contul tău, secțiunea API. La generare
se afișează o singură dată, salveaz-o într-un loc sigur. Dacă o pierzi,
poți regenera cheia (cea veche devine invalida instant). Cheia începe mereu
cu prefixul psk_.
Chei suplimentare. Din aceeași secțiune poți crea mai multe chei pe cont, fiecare cu un nume (de exemplu câte una pentru fiecare integrare). Au aceleași drepturi ca cheia principală și folosesc webhook-ul contului. Revocarea unei chei suplimentare o oprește doar pe ea: cheia principală și celelalte chei rămân valabile.
Erori
Erorile au format consistent:
{
"error": "Mesaj uman-friendly",
"code": "MACHINE_READABLE_CODE"
}
Coduri HTTP
400request malformat (ex. niciun fișier la upload)401cheia API lipsește, are format greșit sau a fost revocată402sold insuficient în wallet403nu ai permisiunea pentru această resursă404resursa nu există422validare eșuată (ex. parametru lipsa, brand interzis pe site)429prea multe request-uri (rate limit)500eroare server (ne contactezi pe articole@psk.ro)503un serviciu extern de care depinde cererea nu răspunde acum (ex. Google Drive ladrive_link); reîncearcă în câteva minute
Unele erori au și un câmp details cu informații structurate (ex. required_eur
la sold insuficient, drive_url la erorile de Google Drive).
Limba mesajelor
Textele din câmpurile error, message și note sunt implicit în română.
Trimite antetul Accept-Language: en ca să le primești în engleză (și numele tipurilor de articol
și ale categoriilor); fără antet răspunsurile sunt în română. Valorile din code sunt aceleași în ambele limbi, deci
integrarea se bazează pe ele. Exemplele din această pagină arată mesajele în română.
Rate limiting
60 request-uri pe minut per cont: toate cheile aceluiași cont împart limita. Când depășești limita primești
HTTP 429 cu header Retry-After (secunde de așteptare).
Fiecare răspuns include header-ul X-Request-Id pentru tracing,
îl poți trimite și tu în request ca să corelezi log-urile.
GET /me
Verificare rapidă a cheii (sanity check). Returnează datele de bază ale contului autentificat. Util ca prim apel când integrezi.
Răspuns 200
{
"id": 123,
"email": "client@exemplu.ro",
"name": "Nume client",
"company": "Firma SRL",
"class": "extern",
"payment_method": "card",
"wallet_balance_eur": 240.50,
"webhook_url": "https://site-ul-tau.ro/webhook",
"webhook_configured": true,
"api_key_created_at": 1716200000,
"api_key_last_used_at": 1716800000
}
GET /products
Lista publicațiilor partenere, grupate: o publicație este un obiect cu un array
offers[] (fiecare ofertă este un tip de articol pe acea publicație).
Prețurile sunt afișate pe tier-ul contului tău. Nu expunem costul de achiziție,
email-ul publicației sau notele interne.
price este prețul pe care îl plătești la POST /orders. Când oferta
are o reducere activa pentru tier-ul tău, price este prețul redus, iar
regular_price, discount_percent și discount_ends_at
(UTC) arată prețul fără reducere, procentul și până când e valabilă. Fără reducere:
discount_percent 0 și discount_ends_at null.
Parametri query
| Parametru | Tip | Descriere |
|---|---|---|
page | opțional | Pagina (min 1, implicit 1) |
per_page | opțional | Rezultate pe pagina (1 până la 200, implicit 50) |
da_min, da_max | opțional | Filtru Domain Authority (0 până la 100) |
category | opțional | ID categorie (întreg) |
search | opțional | Căutare după domeniul publicației |
price_max | opțional | Preț maxim EUR pe cea mai ieftină oferta a publicației |
updated_since | opțional | Doar publicațiile actualizate după această dată |
Răspuns 200
{
"data": [
{
"id": 88,
"name": "exemplu.ro",
"website": "exemplu.ro",
"da": 42, "dr": 38, "spam_score": 2,
"referring_domains": 1200, "traffic": 85000,
"category_id": 5, "category_name": "Știri",
"is_active": true,
"picture_format": "16:9",
"diacritics_required": true,
"updated_at": "2026-05-20T08:00:00Z",
"offers": [
{
"id": 311,
"article_type": "Articol cu brand",
"article_type_id": 57,
"price": 60.00, "currency": "EUR",
"regular_price": 75.00, "discount_percent": 20,
"discount_ends_at": "2026-10-30T22:00:00Z",
"accepts_brand": true, "accepts_homepage": false,
"dofollow_links": 2, "nofollow_links": 0,
"max_images": 3, "turnaround_days": null,
"requirements": "...", "requirements_en": "...",
"sponsorized_label": "Articol sponsorizat",
"is_active": true
}
]
}
],
"meta": { "current_page": 1, "last_page": 12, "per_page": 50, "total": 580 }
}
GET /products/{id}
Detaliile unei singure publicații cu toate ofertele ei. {id} este
ID-ul publicației (câmpul id din lista de mai sus). Răspunde
404 dacă publicația nu există sau nu are oferte active. Structura
obiectului este identică cu un element din data[] la GET /products.
Tipuri de articol
Câmpul offers[].article_type conține numele tipului, exact cum apare
în platforma. Tipurile curente:
Articol fără brandconținut editorial cu link, fără numele brandului (profil natural de link-uri)Articol cu brandnumele și link-urile tale în articolArticol cu promovarepublicare plus vizibilitate în homepage-ul publicațieiInserare linkinserarea unui link într-un articol EXISTENT al publicației (veziinsertion_urlla POST /orders)Brand mentionmenționarea brandului într-un articol existent al publicației- Nișe speciale, acceptate doar de publicațiile listate cu ele:
Articol Bet/Casino,Crypto/Forex/IFN/Banci,Articol Videochat,Articol Sex Shop,Băuturi alcoolice
Lista completă și actuală o ai mereu în răspunsul de la
GET /products: numele vine direct din platforma, nu dintr-o listă statică.
POST /orders
Plasează o comandă de articol pe o ofertă. Suportă 4 surse pentru articol:
docx_uploadîncarci un fișier .docx (acum sau ulterior, prin endpoint-ul de upload)drive_linklink Google Drive public către documentul articolului (.doc, .docx sau Google Docs). Documentul se descarcă în momentul plasării comenzii; dacă nu poate fi citit, comanda nu se creează (vezi Erori la drive_link)redactare_750articol redactat de noi (750 de cuvinte)redactare_1000articol redactat de noi (1000 de cuvinte)
Oferte de tip Inserare link / Brand mention: nu există articol de
livrat, deci article_source NU se trimite. În schimb trimiți
insertion_url (URL-ul articolului EXISTENT al publicației în care se face
inserarea) și, pentru inserare link, keywords cu ancora și linkul tău.
Reguli de validare, aplicate la plasare: articolul țintă trebuie să fie pe domeniul
publicației comandate; articolele care sunt ele însele advertoriale sau publicări
plătite ale altor clienți sunt refuzate (422,
INSERTION_TARGET_BLOCKED); dacă o ancoră nu apare în textul articolului
țintă, comanda se creează și primești insertion_warnings în răspuns:
publicația va adăuga o frază nouă care conține ancora.
Parametri body (JSON sau multipart/form-data)
| Parametru | Tip | Descriere |
|---|---|---|
product_id | obligatoriu | ID-ul ofertei (câmpul offers[].id). Alias acceptat: offer_id |
article_source | obligatoriu* | Una din: docx_upload, drive_link, redactare_750, redactare_1000. *NU se trimite la ofertele Inserare link / Brand mention |
insertion_url | condiționat | Doar la ofertele Inserare link / Brand mention: URL-ul articolului existent al publicației în care se face inserarea. Alias acceptat: existing_article_url |
cover_source | condiționat | URL-ul sursei imaginii de cover. Obligatoriu când publicația cere imagini (max_images > 0); altfel primești 422 COVER_SOURCE_REQUIRED. Alias acceptat: image_source |
project_id | opțional | Proiectul căruia îi aparține comanda (trebuie să fie al tău) |
drive_url | condiționat | Obligatoriu dacă article_source = drive_link: linkul documentului, partajat pe "Oricine are linkul" (Anyone with the link). Forme acceptate: https://docs.google.com/document/d/ID/..., https://drive.google.com/file/d/ID/..., https://drive.google.com/open?id=ID. Documentul (.doc, .docx sau Google Docs, max 25 MB) se descarcă la plasare; la .docx și Google Docs se preiau și imaginile din el. Dacă documentul nu conține nicio imagine și nu trimiți images[], comanda nu se creează (422 COVER_IMAGE_REQUIRED). Alias acceptat: drive_link |
article_file | opțional | Fișier .doc/.docx, max 10 MB (doar pe multipart) |
images | condiționat | Array de imagini, max 10, jpg/jpeg/png/webp/gif, max 5 MB fiecare (doar pe multipart). Orice articol trebuie să aibă o imagine de copertă: dacă documentul (din Drive sau article_file) nu conține nicio imagine, sau comanda e de redactare, trimite cel puțin o imagine aici; altfel primești 422 COVER_IMAGE_REQUIRED și comanda nu se creează. Prima imagine din images[] este coperta și trebuie să respecte formatul publicației: minim 1000 px lățime și raportul lățime/înălțime din picture_format (vezi GET /products; implicit 16:9), cu o abatere de cel mult 30%; altfel primești 422 COVER_FORMAT_INVALID. Când coperta vine din document, cel puțin o imagine din document trebuie să respecte același format |
homepage, brand, dofollow | opțional | Boolean, opțiuni pe articol (dacă oferta le acceptă). Addon-urile cu preț ale publicațiilor nu sunt disponibile prin API; se comandă din platformă |
promoted_keyword | opțional | Cuvânt cheie promovat (max 200) |
promoted_url | opțional | URL promovat. Alias acceptat: target_url |
keywords | opțional | Array (max 10) de { "anchor": "...", "url": "..." } |
notes | opțional | Instrucțiuni pentru redactor (max 2000). Devine primul mesaj pe comandă |
external_reference | opțional | Referință ta internă (max 128). Folosită și pentru idempotență |
webhook_url | opțional | URL webhook specific acestei comenzi (altfel se folosește cel din cont). Cere un webhook configurat în cont, care îți dă secretul de semnătura: fără el primești 422 WEBHOOK_SECRET_MISSING și comanda nu se creează |
idempotency_key | opțional | Cheie unică (max 64). Același key în 24h returnează comanda existentă |
Idempotență
Dacă trimiți din nou un idempotency_key folosit în ultimele 24 de ore,
sau un external_reference deja existent, nu se creează o comandă nouă:
primești comanda existentă cu "idempotent_replay": true.
Fără idempotency_key și fără external_reference, o cerere identică cu una
trimisă în ultimele 90 de secunde (același corp: oferta, proiect, sursa, keywords, notes, coperta,
opțiuni și aceleași fișiere, după nume și dimensiune) e luată drept dublu-click sau retry și
primește tot "idempotent_replay": true cu prima comandă. Două comenzi diferite pe
aceeași ofertă se creează separat; pentru comenzi repetate identice trimite chei diferite.
Răspuns 201
{
"order": { "id": 9001, "status": "processing", "price": 75.00, "currency": "EUR", ... },
"next_steps": {
"type": "upload_required",
"message": "Încarcă articolul (.docx) și imaginile prin POST /api/v1/orders/9001/upload ..."
}
}
Câmpul next_steps.type poate fi: upload_required (sursa docx_upload,
articolul se încarcă prin POST /orders/{id}/upload), in_progress (redactare
de către echipa noastră, inserare, sau sursa drive_link cu documentul deja descărcat și atașat
comenzii: nu mai trebuie niciun upload) sau drive_pending (sursa drive_link, dar
documentul NU este atașat comenzii, la comenzile mai vechi sau dacă salvarea lui a eșuat;
îl încarci tu prin POST /orders/{id}/upload, nu îl descărcăm automat; după upload-ul
documentului, comanda nu îl mai așteaptă și next_steps.type devine in_progress).
Erori la drive_link
Cu article_source = drive_link, documentul se descarcă din Google Drive în momentul
plasării comenzii. Dacă nu poate fi citit, comanda nu se creează și nu se debitează
nimic: primești una din erorile de mai jos, corectezi și retrimiți aceeași cerere. În
details primești drive_url și, la erorile 422, drive_error
(textul erorii, de exemplu răspunsul primit de la Google Drive).
| HTTP | Cod | Ce înseamnă și ce faci |
|---|---|---|
422 | DRIVE_LINK_INVALID | Din link nu se poate scoate ID-ul unui fișier. Trimite unul din formele acceptate la drive_url. |
422 | DRIVE_FILE_NOT_ACCESSIBLE | Fișierul nu există sau nu e partajat public. Setează partajarea pe "Oricine are linkul" (Anyone with the link). |
422 | DRIVE_FILE_UNSUPPORTED | Fișierul nu e un document Word: acceptăm .doc, .docx sau un document Google Docs. |
422 | DRIVE_FILE_TOO_LARGE | Documentul depășește 25 MB (sau limita Google de 10 MB la exportul unui document Google Docs). Micșorează imaginile din el. |
422 | COVER_IMAGE_REQUIRED | Documentul nu conține nicio imagine și nu ai trimis images[]. Orice articol are nevoie de o imagine de copertă: pune una în document sau trimite images[] în aceeași cerere. Comanda nu a fost creată. |
422 | UNPAID_INVOICES | Contul are două sau mai multe facturi lunare neplătite, cel puțin una cu scadența depășită. Comenzile noi sunt blocate cât timp rămân cel puțin două neplătite (plata se face din cont, la Facturi, cu cardul, sau prin transfer bancar). Mesajul listează facturile. |
422 | COVER_FORMAT_INVALID | Imaginea de copertă (prima din images[] sau, când vine din document, oricare imagine din el) nu respectă formatul publicației: minim 1000 px lățime și raportul din picture_format (implicit 16:9), toleranță 30%. Mesajul spune dimensiunile primite și înălțimea corectă. Comanda nu a fost creată. |
503 | DRIVE_UNAVAILABLE | Problema temporară la noi sau la Google. Reîncearcă în câteva minute sau trimite articolul ca fișier, cu article_source = docx_upload. |
{
"error": "Documentul din Google Drive nu poate fi citit: nu există sau nu este partajat public. ... Nu s-a creat nicio comandă.",
"code": "DRIVE_FILE_NOT_ACCESSIBLE",
"details": {
"drive_url": "https://docs.google.com/document/d/.../edit",
"drive_error": "Drive API: File not found: ..."
}
}
GET /orders
Lista comenzilor tale, paginată, cele mai noi primele.
Parametri query
| Parametru | Tip | Descriere |
|---|---|---|
page | opțional | Pagina (min 1, implicit 1) |
per_page | opțional | 1 până la 100, implicit 25 |
status | opțional | Una din: pending, processing, published, failed, rejected. failed înseamnă comandă anulată sau refuzată de publicație (status_internal canceled sau publisher_refused); după un refuz, dacă echipa mută comanda la altă publicație, statusul revine la processing |
project_id | opțional | Filtru pe proiect |
updated_since | opțional | Doar comenzile cu data plasării, a publicării sau a finalizării după această dată. Anularea, refuzul publicației și celelalte schimbări de status nu schimbă aceste date: le primești prin webhook order.status_changed (sau recitești comenzile deschise fără filtru) |
Răspuns 200
{
"data": [
{
"id": 9001, "master_id": "...", "project_id": 4,
"offer_id": 311, "publication": "exemplu.ro", "website": "exemplu.ro",
"status": "published", "status_internal": "complete",
"price": 75.00, "currency": "EUR", "total_eur": 75.00,
"external_reference": "ref-123",
"order_date": 1716200000, "created_at": "2026-05-20T08:00:00Z",
"updated_at": "2026-05-22T10:00:00Z",
"published_url": "https://exemplu.ro/articol", "publish_url": "https://exemplu.ro/articol",
"published_at": "2026-05-22T10:00:00Z", "publish_date": 1716372000
}
],
"meta": { "page": 1, "per_page": 25, "total": 40, "last_page": 2 }
}
GET /orders/{id}
Detaliile complete ale unei comenzi. Pe lângă câmpurile din lista, răspunsul
include și cover_source, image_source, article_type,
keywords, target_url, insertion_url,
insertion_warnings, notes și webhook_url.
Răspunde 404 dacă comanda nu există sau nu îți aparține.
POST /orders/{id}/upload
Încarcă fișierele unei comenzi (articol .docx și/sau imagini). Folosit mai ales
pentru comenzile cu sursa docx_upload. Când articolul .docx ajunge,
comanda intră în procesare: echipa verifică articolul și îl trimite la publicație (status_internal processing; statusul public rămâne processing). Documentul se primește doar împreună cu o imagine de
copertă (în aceeași cerere, în document sau deja existentă pe comandă); altfel primești
422 COVER_IMAGE_REQUIRED și niciun fișier din cerere nu se salvează. La o comandă care nu are încă nicio imagine, prima imagine din cerere (sau o imagine din document) devine coperta și trebuie să respecte formatul publicației (picture_format, minim 1000 px lățime, toleranță 30%); altfel primești 422 COVER_FORMAT_INVALID și nimic nu se salvează.
Request: multipart/form-data
| Câmp | Tip | Descriere |
|---|---|---|
files[] | obligatoriu | Unul sau mai multe fișiere. Acceptate: .doc, .docx, .jpg, .jpeg, .png, .webp, .gif. Max 10 MB fiecare, max 5 fișiere per request |
Răspuns 200
{
"uploaded": [
{ "id": 5001, "media_type": "document", "filename": "articol.docx", "url": "https://articole.psk.ro/storage/orders/9001/..." }
],
"order_status": "processing"
}
GET /orders/{id}/messages
Mesajele de pe comandă (conversația cu echipa). Mesajele interne nu sunt expuse, la fel ca în interfața clientului.
Răspuns 200
{
"data": [
{ "from": "Nume client", "message": "Vă rog să folosiți tonul formal.", "date": 1716200100 }
]
}
POST /orders/{id}/messages
Adaugă un mesaj pe comandă. Echipa noastră este notificată pe email.
Parametri body
| Parametru | Tip | Descriere |
|---|---|---|
message | obligatoriu | Text, între 1 și 5000 de caractere |
Răspuns 201
{ "status": "sent", "date": 1716200200 }
GET /wallet
Soldul portofelului. TVA-ul se aplică în funcție de profilul tău fiscal.
Răspuns 200
{
"balance_net_eur": 240.50,
"balance_gross_eur": 291.01,
"currency": "EUR",
"apply_vat": true
}
GET /wallet/transactions
Istoricul tranzacțiilor din wallet, paginat.
Parametri query
| Parametru | Tip | Descriere |
|---|---|---|
page | opțional | Pagina (min 1, implicit 1) |
per_page | opțional | 1 până la 100, implicit 25 |
Răspuns 200
{
"data": [
{ "id": 12, "type": "credit", "amount_net_eur": 100.00, "description": "Alimentare card", "order_id": null, "created_at": 1716200000 }
],
"meta": { "page": 1, "per_page": 25, "total": 12, "last_page": 1 }
}
POST /wallet/topup
Inițiază o alimentare de wallet prin card. Răspunsul conține datele de
plată: faci un HTTP POST către action_url cu câmpurile din
transfer_data (form-urlencoded). După plata, banca confirmă prin IPN
și wallet-ul se creditează automat.
Parametri body
| Parametru | Tip | Descriere |
|---|---|---|
amount_net_eur | obligatoriu | Suma netă în EUR, între 20 și 5000 |
Răspuns 200
{
"master_order_id": 7700,
"amount_net_eur": 100.00,
"amount_gross": 121.00,
"currency": "RON",
"payment": {
"action_url": "https://...netopia-payments...",
"transfer_data": { "...": "..." },
"method": "POST",
"note": "Trimite POST către action_url cu câmpurile din transfer_data (form-urlencoded)."
}
}
Răspuns 422
Pentru conturile din România fără județ și localitate în Contul meu: alimentarea se facturează automat, iar factura are nevoie de ele. Completează-le o singură dată, apoi reia cererea.
{
"error": "Contul nu are județul și localitatea de care are nevoie factura. ...",
"code": "BILLING_ADDRESS_INCOMPLETE"
}
GET /projects
Lista proiectelor tale, paginată.
Parametri query
| Parametru | Tip | Descriere |
|---|---|---|
page | opțional | Pagina (min 1, implicit 1) |
per_page | opțional | 1 până la 100, implicit 50 |
Răspuns 200
{
"data": [
{ "id": 4, "name": "Campanie primavara", "url": "exemplu.ro", "description": null }
],
"meta": { "page": 1, "per_page": 50, "total": 4, "last_page": 1 }
}
POST /projects
Creează un proiect nou. Proiectele grupează comenzile (le poți referi prin
project_id la POST /orders).
Parametri body
| Parametru | Tip | Descriere |
|---|---|---|
name | obligatoriu | Numele proiectului (max 64) |
url | obligatoriu | Domeniul promovat, fără protocol (ex. exemplu.ro) |
Răspuns 201
{ "id": 5, "name": "Campanie vara", "url": "exemplu.ro", "description": null }
Datele de facturare per proiect (billing override) se configurează din interfața platformei, nu prin API.
GET /projects/{id}
Detaliile unui proiect. Răspunde 404 dacă proiectul nu există sau
nu îți aparține.
Webhook-uri
Când statusul unei comenzi se schimbă, îți trimitem un POST către URL-ul
tău, ca să nu fie nevoie de polling. Configurezi webhook_url și
webhook_secret în contul tău (secțiunea API), sau trimiți un
webhook_url per comandă la POST /orders.
Evenimente
order.createdcomanda a fost creatăorder.status_changedstatusul public s-a schimbat (includeold_statusșinew_status)order.changes_requesteds-au cerut modificări la comandă; includemessage(textul cu ce trebuie modificat). Statusul public rămâneprocessing. Răspunzi prinPOST /orders/{id}/messagesși/sau re-încarci articolul cuPOST /orders/{id}/upload(câmpfiles[]).
Livrare și retry
Webhook-urile sunt trimise de un proces care rulează în fiecare minut. Pe răspuns
5xx, 408, 429 sau timeout (10 secunde) reîncercăm cu backoff: după 1 minut, 5 minute, apoi
30 de minute. După 3 încercări eșuate, livrarea e marcată failed. Un răspuns 3xx sau 4xx (altul decât 408 și 429) marchează livrarea failed din prima: redirecturile nu se urmează, iar un 404 nu se schimbă la reîncercare.
Fiecare request poartă header-ele X-PSK-Signature, X-PSK-Event
și X-PSK-Delivery-Id.
Exemplu payload
{
"event": "order.status_changed",
"order_id": 9001,
"master_id": "...",
"status": "published",
"status_internal": "complete",
"old_status": "processing",
"new_status": "published",
"project_id": 4,
"publication": "exemplu.ro",
"website": "exemplu.ro",
"price": 75.00, "currency": "EUR", "total_eur": 75.00,
"external_reference": "ref-123",
"published_url": "https://exemplu.ro/articol",
"publish_url": "https://exemplu.ro/articol",
"published_at": "2026-05-22T10:00:00Z",
"timestamp": "2026-05-22T10:00:01Z"
}
GET /webhooks/deliveries
Istoricul livrărilor de webhook, util pentru debugging (vezi de ce a eșuat un webhook anume).
Parametri query
| Parametru | Tip | Descriere |
|---|---|---|
page | opțional | Pagina (min 1, implicit 1) |
per_page | opțional | 1 până la 100, implicit 25 |
status | opțional | Una din: pending, success, failed |
Răspuns 200
{
"data": [
{
"id": 41, "event": "order.status_changed", "order_id": 9001,
"webhook_url": "https://site-ul-tau.ro/webhook",
"status": "success", "http_status": 200, "attempts": 1,
"response_body": "ok",
"created_at": 1716372001, "delivered_at": 1716372002, "next_attempt_at": 1716372001,
"payload": { "...": "..." }
}
],
"meta": { "page": 1, "per_page": 25, "total": 41, "last_page": 2 }
}
Verificare semnătura webhook
Fiecare cerere webhook trimisă de noi include header-ul X-PSK-Signature
cu valoarea sha256=<hex>, calculată ca HMAC-SHA256 al body-ului
folosind webhook_secret din contul tău.
Exemplu PHP:
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_PSK_SIGNATURE'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $payload, $WEBHOOK_SECRET);
if (!hash_equals($expected, $signature)) {
http_response_code(403);
exit;
}
// ... payload-ul este de încredere