е-Фактура API
v1 Водичи EN Започнете

е-Фактура API

Webhooks

Создавање endpoint

curl -X POST "https://api.efaktura.merot.com/v1/webhook-endpoints" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/hooks/merot-efaktura",
    "events": [
      "einvoice.submitted",
      "einvoice.rejected",
      "inbox.received",
      "inbox.deadline_approaching"
    ]
  }'
const response = await fetch("https://api.efaktura.merot.com/v1/webhook-endpoints", {
  method: "POST",
  headers: {
    "Authorization": "Bearer YOUR_API_KEY",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "url": "https://example.com/hooks/merot-efaktura",
    "events": [
      "einvoice.submitted",
      "einvoice.rejected",
      "inbox.received",
      "inbox.deadline_approaching"
    ]
  }),
});

const data = await response.json();
console.log(data);
import requests

response = requests.post(
    "https://api.efaktura.merot.com/v1/webhook-endpoints",
    headers={
    "Authorization": "Bearer YOUR_API_KEY",
    "Content-Type": "application/json",
    },
    json={
    "url": "https://example.com/hooks/merot-efaktura",
    "events": [
        "einvoice.submitted",
        "einvoice.rejected",
        "inbox.received",
        "inbox.deadline_approaching"
    ]
},
)

print(response.json())
<?php
// composer require guzzlehttp/guzzle
require 'vendor/autoload.php';

$client = new GuzzleHttp\Client();
$response = $client->request('POST', 'https://api.efaktura.merot.com/v1/webhook-endpoints', [
    'headers' => [
        'Authorization' => 'Bearer YOUR_API_KEY',
        'Content-Type' => 'application/json',
    ],
    'json' => [
    'url' => 'https://example.com/hooks/merot-efaktura',
    'events' => [
        'einvoice.submitted',
        'einvoice.rejected',
        'inbox.received',
        'inbox.deadline_approaching',
    ],
],
]);

echo $response->getBody();
using System.Net.Http;
using System.Text;

using var client = new HttpClient();
using var request = new HttpRequestMessage(HttpMethod.Post, "https://api.efaktura.merot.com/v1/webhook-endpoints");
request.Headers.Add("Authorization", "Bearer YOUR_API_KEY");
var json = /* lang=json */ @"{
  ""url"": ""https://example.com/hooks/merot-efaktura"",
  ""events"": [
    ""einvoice.submitted"",
    ""einvoice.rejected"",
    ""inbox.received"",
    ""inbox.deadline_approaching""
  ]
}";
request.Content = new StringContent(json, Encoding.UTF8, "application/json");

using var response = await client.SendAsync(request);
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://api.efaktura.merot.com/v1/webhook-endpoints"))
    .headers("Authorization", "Bearer YOUR_API_KEY", "Content-Type", "application/json")
    .method("POST", HttpRequest.BodyPublishers.ofString(/* JSON */ "{   \"url\": \"https://example.com/hooks/merot-efaktura\",   \"events\": [     \"einvoice.submitted\",     \"einvoice.rejected\",     \"inbox.received\",     \"inbox.deadline_approaching\"   ] }"))
    .build();

HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());

Тајниот клуч secret (whsec_...) во одговорот се прикажува само еднаш — зачувајте го. Ротирањето на тајниот клуч го задржува стариот важечки за потпишување уште 24 часа, така што и двата се прифатени додека го внесувате новиот.

Настани

НастанКога се испраќа
einvoice.validated_failedИздавањето или валидацијата не поминале според правилата на УЈП.
einvoice.signature_requiredБарање за потпишување чека на вашиот агент.
einvoice.submittedУЈП доделил EUID — status: "delivered".
einvoice.accepted / einvoice.auto_acceptedКупувачот прифатил, експлицитно или по молчење.
einvoice.rejectedКупувачот одбил, со причина.
einvoice.cancelled_by_storno / einvoice.correctedПоднесено е поврзано сторно/корекција.
einvoice.failedКрајна грешка.
inbox.receivedПристигнала нова влезна е-фактура.
inbox.deadline_approachingОстанати се 5, 2 или 1 ден до крајниот рок (видете Животен циклус на документ).
inbox.auto_acceptedРокот поминал без одлука.
signing_agent.offline / signing_agent.onlineСе променила состојбата на конекцијата на вашиот Bridge/SDK-агент.
archive.export_readyГрупен извоз од архивата завршил.

Испораката не е гарантирано подредена — користете ги createdAt на настанот и сопственото updatedAt на објектот, не редоследот на пристигнување.

Проверка на потписи

Секоја испорака носи Merot-Signature: t=<unix временска ознака>,v1=<hex HMAC-SHA256 на "t.rawBody">, пресметан со тајниот клуч на вашиот endpoint — истата шема што ја популаризираше Stripe. Одбивајте сè каде временската ознака е постара од 300 секунди, и споредувајте ги дигестите во константно време.

# Проверката на потписот е локална пресметка во вашиот webhook handler, не HTTP
# повик — видете ги табовите Node.js / Python / PHP / C# / Java за целосна имплементација.
import crypto from "node:crypto";

function verifyMerotSignature(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=").map((s) => s.trim())));
const timestamp = Number(parts.t);
if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) {
  throw new Error("Timestamp outside tolerance");
}
const expected = crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
const provided = Buffer.from(parts.v1 ?? "", "utf8");
const expectedBuf = Buffer.from(expected, "utf8");
if (provided.length !== expectedBuf.length || !crypto.timingSafeEqual(provided, expectedBuf)) {
  throw new Error("Signature mismatch");
}
}

// Express: app.post("/hooks/efaktura", express.raw({ type: "application/json" }), (req, res) => {
//   verifyMerotSignature(req.body.toString("utf8"), req.header("Merot-Signature"), process.env.WEBHOOK_SECRET);
//   ...
// });
import hashlib
import hmac
import time


def verify_merot_signature(raw_body: bytes, header: str, secret: str, tolerance_seconds: int = 300) -> None:
  parts = dict(kv.split("=", 1) for kv in header.split(","))
  timestamp = int(parts["t"])
  if abs(time.time() - timestamp) > tolerance_seconds:
      raise ValueError("Timestamp outside tolerance")
  signed_payload = f"{timestamp}.".encode() + raw_body
  expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
  if not hmac.compare_digest(expected, parts["v1"]):
      raise ValueError("Signature mismatch")
<?php
function verify_merot_signature(string $rawBody, string $header, string $secret, int $toleranceSeconds = 300): void
{
  $parts = [];
  foreach (explode(',', $header) as $kv) {
      [$key, $value] = explode('=', $kv, 2);
      $parts[$key] = $value;
  }
  $timestamp = (int) ($parts['t'] ?? 0);
  if (abs(time() - $timestamp) > $toleranceSeconds) {
      throw new RuntimeException('Timestamp outside tolerance');
  }
  $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
  if (!hash_equals($expected, $parts['v1'] ?? '')) {
      throw new RuntimeException('Signature mismatch');
  }
}
using System;
using System.Linq;
using System.Security.Cryptography;
using System.Text;

public static class MerotWebhookSignature
{
  public static void Verify(string rawBody, string header, string secret, int toleranceSeconds = 300)
  {
      var parts = header.Split(',')
          .Select(kv => kv.Split('=', 2))
          .ToDictionary(kv => kv[0], kv => kv[1]);

      var timestamp = long.Parse(parts["t"]);
      var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
      if (Math.Abs(now - timestamp) > toleranceSeconds)
          throw new InvalidOperationException("Timestamp outside tolerance");

      var signedPayload = Encoding.UTF8.GetBytes($"{timestamp}.{rawBody}");
      using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
      var expected = Convert.ToHexString(hmac.ComputeHash(signedPayload)).ToLowerInvariant();

      var a = Encoding.UTF8.GetBytes(expected);
      var b = Encoding.UTF8.GetBytes(parts["v1"]);
      if (a.Length != b.Length || !CryptographicOperations.FixedTimeEquals(a, b))
          throw new InvalidOperationException("Signature mismatch");
  }
}
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HashMap;
import java.util.Map;

public class MerotWebhookSignature {
  public static void verify(String rawBody, String header, String secret, int toleranceSeconds) throws Exception {
      Map<String, String> parts = new HashMap<>();
      for (String kv : header.split(",")) {
          String[] pair = kv.split("=", 2);
          parts.put(pair[0], pair[1]);
      }
      long timestamp = Long.parseLong(parts.get("t"));
      long now = System.currentTimeMillis() / 1000;
      if (Math.abs(now - timestamp) > toleranceSeconds) {
          throw new IllegalStateException("Timestamp outside tolerance");
      }
      String signedPayload = timestamp + "." + rawBody;
      Mac mac = Mac.getInstance("HmacSHA256");
      mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
      byte[] hash = mac.doFinal(signedPayload.getBytes(StandardCharsets.UTF_8));
      StringBuilder hex = new StringBuilder();
      for (byte b : hash) hex.append(String.format("%02x", b));
      if (!MessageDigest.isEqual(hex.toString().getBytes(StandardCharsets.UTF_8), parts.get("v1").getBytes(StandardCharsets.UTF_8))) {
          throw new IllegalStateException("Signature mismatch");
      }
  }
}

Важно

Секогаш проверувајте според точните бајти на телото на барањето, пред каква било JSON парсинг — повеќето рамки стандардно го парсираат телото вместо вас, а тоа ги менува празните места и го расипува потписот. Конфигурирајте посебна рута со сурово (raw) тело за вашиот webhook endpoint.

Повторни обиди

Merot повторува неуспешна испорака со зголемувачки паузи (1 мин, 5 мин, 30 мин, 2 ч, 6 ч, 12 ч — вкупно околу 21 час) пред да ја означи како напуштена (abandoned). Endpoint што само неуспева 72 часа по ред автоматски се деактивира, а сопствениците добиваат е-маил. Повторете испорака на кој било настан рачно од конзолата или преку POST /v1/events/{id}/replay.