Invoice Synchronization

Invoice synchronization lets an external system replace the customer-facing invoices that vivenu generates with its own. This is useful for sellers who are required to issue invoices in a specific format.

To use it, the seller sets up a connector: an endpoint that accepts an invoice in the vivenu format and turns it into the customer-facing invoice.

How it works

  1. vivenu calls your connector. The request contains the invoice vivenu issued, plus additional information, in JSON format. If the connector is configured with an API key, it can use that key to fetch any further information it needs to build the invoice in the external system.
  2. Your connector creates the invoice in the external system.
  3. Your connector returns a reference to that invoice. vivenu then uses this reference everywhere the invoice is shown to the customer.

Your connector returns a single reference, documentUrl. It has to be a URL pointing to a location where the invoice can be viewed or downloaded, and it is used in all customer-facing communication, including email confirmations and receipts.

When the hook runs

Unlike a webhook, which vivenu fires and forgets, this hook runs in-sync with the business logic that creates the invoice. The operation that triggered it — a checkout, a POS transaction, a cancellation — is paused until your connector answers, and it fails if the answer does not arrive in time. Treat the connector as part of that critical path: keep it fast, and move anything that is not needed to produce the documentUrl out of the request handler.

With that in mind, the hook runs whenever vivenu creates an invoice:

  • On invoice creation, for example as part of a checkout or a POS transaction. The request contains that invoice under data.invoice.
  • When an invoice is cancelled. Invoices are never modified in place, so a cancellation produces a negated invoice — a new invoice that reverses the original. That negated invoice arrives under data.invoice, with the original invoice as data.referenceInvoice.
  • When an invoice is re-issued. A re-issue is a cancellation followed by a new issue, so it triggers the hook twice: first for the negated invoice referencing the original, then for the re-issued invoice, which references the negated invoice as data.referenceInvoice.

Payload structure

Every invoice synchronization request contains the newly created invoice under data.invoice. If that invoice refers to an earlier one — as described above — the referenced invoice is included under data.referenceInvoice. The idempotencyId lets you make sure the same request is not processed twice in case of retries, and hookRequest is always set to invoice_synchronization.

type InvoiceSynchronizationRequest = {
  idempotencyId: string
  hookRequest: 'invoice_synchronization'
  data: {
    invoice: Invoice
    referenceInvoice?: Invoice // the invoice data.invoice refers to
  }
}

Both invoices follow the structure documented in the Invoices API. Please note that external will be an empty object, and that the secret is omitted. The structure can get complex, so for a quick start it may help to export a few invoices from the vivenu dashboard and work from real data.


Reference implementation

The following is a reference implementation of a connector: a small hono server that receives the invoice issued by vivenu and returns a reference to the invoice in a fictional external system. Persistence is handled by lowdb, a JSON file store that stands in for whatever datastore you already run. Treat the whole thing as a starting point — you will need to adapt it to the requirements of your external system.

The implementation is designed to be deployed per seller, with a dedicated API key.

Prerequisites

  • An API key from the vivenu dashboard. As a third party, you may need to ask the seller to provide one. The example reads it from process.env.VIVENU_API_KEY.
  • An HMAC key to verify that incoming requests really come from vivenu. The example reads it from process.env.HMAC_KEY.
  • A host for the connector that is reachable from the public internet. The example runs a hono server at a public URL, read from process.env.PUBLIC_URL.
  • The API host to talk to, read from process.env.VIVENU_API_HOST: https://vivenu.dev while you develop, https://vivenu.com in production.

Configuration

The hook configuration lives in the same object as the environment configuration. Keeping it in code rather than in the dashboard means the connector owns it: on every start the configuration is pushed to vivenu, so changing the endpoint or the HMAC key is a deploy, not a manual step.

const config = {
  // The hook configuration that will be sent to the vivenu API when registering
  // the hook. Any change here auto-updates the hook on vivenu.com when the
  // server is restarted, using an update request to the vivenu API.
  hook: {
    name: 'Hono Invoice Synchronization Handler',
    enabled: true,
    execution: 'http',
    events: ['invoice.synchronization.request'],
    params: {
      url: `${process.env.PUBLIC_URL}/api/invoice-synchronization`,
      hmacKey: process.env.HMAC_KEY,
    },
  },

  PUBLIC_URL: process.env.PUBLIC_URL!,
  HMAC_KEY: process.env.HMAC_KEY!,

  VIVENU_API_HOST: process.env.VIVENU_API_HOST!, // either https://vivenu.com or https://vivenu.dev
  VIVENU_API_KEY: process.env.VIVENU_API_KEY!,
}

// A very basic wrapper around fetch to make authenticated requests. In
// production ready environments, this should be replaced with a more robust
// HTTP client that handles retries, timeouts, and error handling.
async function http<Res>(method: string, url: string, body: any): Promise<Res> {
  const res = await fetch(url, {
    method: method,
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${config.VIVENU_API_KEY}`,
    },
    body: JSON.stringify(body),
  })
  return res.json() as Promise<Res>
}

State

The connector needs to remember two things: the id of the hook it registered, so it can update that hook instead of creating a second one (which would fail), and the ids of the requests it has already handled, so a retry does not produce a duplicate invoice. Persisting those idempotency ids is optional, but recommended.

The example keeps both in a JSON file, next to a second file that stands in for the external system. Replace both with real storage.

import { JSONFileSyncPreset } from 'lowdb/node'

const databases = {
  // The connector database is a placeholder for a datastore which keeps track of
  // the id of the hook registered with vivenu.com, so it can be updated later.
  // Optionally, it keeps track of idempotency ids to avoid processing the same
  // invoice multiple times.
  connector: JSONFileSyncPreset('connector.json', {
    hookId: undefined as string | undefined,
    idempotencyIds: [] as string[],
  }),

  // The external database is a placeholder for any downstream service which
  // handles, issues or provides invoices — a datastore which is populated by
  // this connector.
  external: JSONFileSyncPreset('external.json', {
    invoices: [] as { _id: string }[],
  }),
}

Step 1: Register the hook

First, make sure vivenu can reach you. Register a hook that vivenu calls whenever an invoice is issued, authenticated with the API key from the prerequisites. The hook points at the connector endpoint that handles invoice synchronization requests and carries the HMAC key that vivenu uses to sign its requests.

Run this on every start. If a hook id is already stored, the stored hook is updated in place; otherwise a new one is created and its id persisted. That keeps restarts and redeploys from piling up duplicate hooks on the same seller.

async function ensureHookRegistration() {
  const hookId = databases.connector.data.hookId
  if (hookId) {
    // send an update to ensure the hook is up to date with the latest configuration
    await http<{ _id: string }>(
      'PUT',
      `${config.VIVENU_API_HOST}/api/hooks/${hookId}`,
      config.hook,
    )
    return
  }

  // we don't have any hook configured yet, so we need to register a new one
  const result = await http<{ _id: string }>(
    'POST',
    `${config.VIVENU_API_HOST}/api/hooks`,
    config.hook,
  )
  databases.connector.data.hookId = result._id
  databases.connector.write()
}

Step 2: Verify the signature

With the hook in place, invoice synchronization requests start arriving. vivenu sends each one as a JSON POST to the URL you configured, signed with the x-vivenu-signature header. Verify that signature before doing any work: compute the HMAC of the raw request body with your HMAC key and sha256, and compare it to the header in constant time.

import { createHmac, timingSafeEqual } from 'node:crypto'

async function compareHmac(text: string, signature: string): Promise<boolean> {
  const expectedSignature = createHmac('sha256', config.HMAC_KEY!)
    .update(text)
    .digest('hex')

  return (
    signature.length === expectedSignature.length &&
    timingSafeEqual(
      Buffer.from(signature.toLowerCase()),
      Buffer.from(expectedSignature.toLowerCase()),
    )
  )
}

Step 3: Create the invoice in the external system

Now that the request is known to be legitimate, transform the invoice and create it in the external system. The fictional system used here is 1:1 compatible with the vivenu invoice format. In practice the transformation is usually more involved, and may require additional information fetched from vivenu with your API key.

async function createInvoiceInExternalSystem(
  request: InvoiceSynchronizationRequest,
): Promise<{ documentUrl: string }> {
  // Make subsequent api calls to the vivenu api, but most importantly, transform
  // the invoice into the format that your external system expects and create it
  // there. We just persist the invoice in our local external database for
  // demonstration purposes.
  const newIndex = databases.external.data.invoices.push({
    _id: request.data.invoice._id,
  })
  databases.external.write()

  // The URL below is for demonstration only. documentUrl should either point at
  // a PDF directly or at a web page that offers the invoice for download.
  return {
    documentUrl: `https://example.org/invoices/${newIndex - 1}.pdf`,
  }
}

Step 4: Wire it together

The entrypoint registers the hook, then serves the endpoint. The route path is derived from the hook configuration by stripping the public URL, so the endpoint and the registered URL cannot drift apart.

Each request runs through the same four checks: verify the signature, reject an idempotencyId that has already been handled, create the invoice, then record the idempotencyId and return the reference.

import { serve } from '@hono/node-server'
import { Hono } from 'hono'

async function main() {
  // Step 1: Ensure that the hook is registered with vivenu.com. If it is already
  // registered, this will update the hook configuration.
  await ensureHookRegistration()

  const app = new Hono()

  // Step 2: Register the endpoint that we configured in the hook. We derive the
  // endpoint from the hook configuration, so that we don't have to hardcode it
  // in multiple places.
  app.post(config.hook.params.url.replace(config.PUBLIC_URL, ''), async (c) => {
    const text = await c.req.text()

    // Step 3: Validate the HMAC signature to ensure that the request is coming
    // from vivenu.com and not from a malicious actor. Then parse the body and
    // optionally check for idempotency.
    if (!(await compareHmac(text, c.req.header('x-vivenu-signature') ?? ''))) {
      return c.json({ error: 'Invalid HMAC' }, 401)
    }

    const body = JSON.parse(text) as InvoiceSynchronizationRequest
    if (databases.connector.data.idempotencyIds.includes(body.idempotencyId)) {
      return c.json({ error: 'Duplicate idempotencyId' }, 409)
    }

    // Step 4: Transform the vivenu invoice and create it in the external system.
    const reference = await createInvoiceInExternalSystem(body)

    // Step 5: Store the idempotencyId in the connector database to avoid
    // processing the same invoice multiple times. And return the reference to
    // the created invoice in the external system to vivenu.com.
    databases.connector.data.idempotencyIds.push(body.idempotencyId)
    databases.connector.write()

    return c.json({ documentUrl: reference.documentUrl }, 200)
  })

  serve({ fetch: app.fetch, port: 3333 }, (info) => {
    console.log(`Server is running on http://localhost:${info.port}`)
  })
}

main().catch((err) => {
  console.error('Error starting the server:', err)
  process.exit(1)
})

The complete connector

The whole thing in one file, ready to copy. It needs hono, @hono/node-server and lowdb, and the four environment variables from the prerequisites.

connector.ts

import { serve } from '@hono/node-server'
import { Hono } from 'hono'
import { JSONFileSyncPreset } from 'lowdb/node'
import { createHmac, timingSafeEqual } from 'node:crypto'

// A very basic wrapper around fetch to make authenticated requests. In production ready
// environments, this should be replaced with a more robust HTTP client that handles retries, timeouts, and error handling.
async function http<Res>(method: string, url: string, body: any): Promise<Res> {
  const res = await fetch(url, {
    method: method,
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${config.VIVENU_API_KEY}`,
    },
    body: JSON.stringify(body),
  })
  return res.json() as Promise<Res>
}

const config = {
  // The hook configuration that will be sent to the vivenu API when registering the hook.
  // Any changes to this configuration will auto-update the hook on vivenu.com when the server is restarted, using
  // an update request to the vivenu API.
  hook: {
    name: 'Hono Invoice Synchronization Handler',
    enabled: true,
    execution: 'http',
    events: ['invoice.synchronization.request'],
    params: {
      url: `${process.env.PUBLIC_URL}/api/invoice-synchronization`,
      hmacKey: process.env.HMAC_KEY,
    },
  },

  PUBLIC_URL: process.env.PUBLIC_URL!,
  HMAC_KEY: process.env.HMAC_KEY!,

  VIVENU_API_HOST: process.env.VIVENU_API_HOST!, // either https://vivenu.com or https://vivenu.dev
  VIVENU_API_KEY: process.env.VIVENU_API_KEY!,
}

const databases = {
  // The connector database is used as a placeholder for a datastore which should keep track of the id of the hook that
  // was registered with vivenu.com, so it can be updated later.
  // Optionally, it can keep track of idempotency ids to avoid processing the same invoice multiple times.
  connector: JSONFileSyncPreset('connector.json', {
    hookId: undefined as string | undefined,
    idempotencyIds: [] as string[],
  }),

  // The external database is a placeholder for any downstream service which handles / issues or provides invoices - A datastore which is populated
  // by this connector.
  external: JSONFileSyncPreset('external.json', {
    invoices: [] as { _id: string }[],
  }),
}

async function ensureHookRegistration() {
  const hookId = databases.connector.data.hookId
  if (hookId) {
    // send an update to ensure the hook is up to date with the latest configuration
    await http<{ _id: string }>(
      'PUT',
      `${config.VIVENU_API_HOST}/api/hooks/${hookId}`,
      config.hook,
    )
    return
  }

  // we don't have any hook configured yet, so we need to register a new one
  const result = await http<any>(
    'POST',
    `${config.VIVENU_API_HOST}/api/hooks`,
    config.hook,
  )
  databases.connector.data.hookId = result._id
  databases.connector.write()
}

async function compareHmac(text: string, signature: string): Promise<boolean> {
  const expectedSignature = createHmac('sha256', config.HMAC_KEY!)
    .update(text)
    .digest('hex')

  return (
    signature.length === expectedSignature.length &&
    timingSafeEqual(
      Buffer.from(signature.toLowerCase()),
      Buffer.from(expectedSignature.toLowerCase()),
    )
  )
}

async function createInvoiceInExternalSystem(
  request: InvoiceSynchronizationRequest,
): Promise<{ documentUrl: string }> {
  // Make subsequent api calls to the vivenu api, but most importantly, transform the invoice into the format that your external system expects and create it there.
  // We just persist the invoice in our local external database for demonstration purposes, but in a real world scenario, you would create the invoice in your external system here.
  const newIndex = databases.external.data.invoices.push({
    _id: request.data.invoice._id,
  })
  databases.external.write()

  // The URL below is for demonstration only. documentUrl should either point at
  // a PDF directly or at a web page that offers the invoice for download.
  return {
    documentUrl: `https://example.org/invoices/${newIndex - 1}.pdf`,
  }
}

type InvoiceSynchronizationRequest = {
  idempotencyId: string
  hookRequest: 'invoice_synchronization'
  data: {
    invoice: Invoice
    referenceInvoice?: Invoice // the invoice data.invoice refers to
  }
}

type Invoice = any // See "Payload structure" above for the nested structure

async function main() {
  // Step 1: Ensure that the hook is registered with vivenu.com. If it is already registered, this will update the hook configuration.
  await ensureHookRegistration()

  const app = new Hono()

  // Step 2: Register the endpoint that we configured in the hook. We derive the endpoint from the hook configuration, so that we don't have to hardcode it in multiple places.
  app.post(config.hook.params.url.replace(config.PUBLIC_URL, ''), async (c) => {
    const text = await c.req.text()

    // Step 3: Validate the HMAC signature to ensure that the request is coming from vivenu.com and not from a malicious actor. Then parse the body and optionally check for idempotency.
    if (!(await compareHmac(text, c.req.header('x-vivenu-signature') ?? ''))) {
      return c.json({ error: 'Invalid HMAC' }, 401)
    }

    const body = JSON.parse(text) as InvoiceSynchronizationRequest
    if (databases.connector.data.idempotencyIds.includes(body.idempotencyId)) {
      return c.json({ error: 'Duplicate idempotencyId' }, 409)
    }

    // Step 4: Transform the vivenu invoice and create it in the external system.
    const reference = await createInvoiceInExternalSystem(body)

    // Step 5: Store the idempotencyId in the connector database to avoid processing the same invoice multiple times. And return the reference to the created invoice in the external system to vivenu.com.
    databases.connector.data.idempotencyIds.push(body.idempotencyId)
    databases.connector.write()

    return c.json({ documentUrl: reference.documentUrl }, 200)
  })

  serve(
    {
      fetch: app.fetch,
      port: 3333,
    },
    (info) => {
      console.log(`Server is running on http://localhost:${info.port}`)
    },
  )
}

main().catch((err) => {
  console.error('Error starting the server:', err)
  process.exit(1)
})

Was this page helpful?