On this page

#Ruby gem

vatio-sdk is the server side of a Vatio integration in Ruby and Rails. It does two separate jobs, in two separate requires:

RequireWhat it doesTalks to Vatio?
vatio/identitySigns the JWT that tells your agent who the user is, and verifies it when a private tool calls your APINever: signing and verifying are local
vatio/serverA client for the server API, so the agent writes to someone first on WhatsApp, plus the receiver for its webhooksYes, only when you call it

An app that only signs tokens never loads the second.

gem "vatio-sdk", "~> 0.6"

It is on RubyGems from 0.6.0, and its only dependency is the jwt gem. Until then it was called vatio-identity and installed from git; a Gemfile that still says gem "vatio-identity", git: "https://github.com/vatio-ai/ruby" keeps working. The source and the full README are at github.com/vatio-ai/ruby.

#Setup

The keypair comes from the CLI: vatio auth --new-key writes identity.pub and identity.pem in the workspace, gitignores the private half and prints the auth: block for vatio.yml. Vatio holds only the public half, so it can check a token and never mint one.

# config/initializers/vatio.rb
Vatio::Identity.configure do |c|
  c.audience    = "acme"                                # your workspace slug
  c.private_key = ENV.fetch("VATIO_IDENTITY_PRIVATE_KEY") # the PEM, not a path
end
SettingDefault
audiencenone, requiredYour workspace slug. Vatio refuses a token whose aud is not the slug
private_keynoneThe PEM that signs. A public key here is refused
public_keythe private keyOnly to verify without the private key
algorithmfrom the keyES256/ES384/ES512 from the EC curve, RS256 for RSA. Must match auth.algorithm in vatio.yml
expires_in3600Seconds a widget token lasts

Read the key from an environment variable or your secrets store: a private key in the repo is a private key that leaked.

#Identify users on the web

<%= raw Vatio::Identity.widget_tag(
      workspace: "acme",
      token: "vatpub_...",
      subject: current_user&.id,
      claims: { name: current_user&.name, email: current_user&.email }) %>

It renders the widget's script tag with a visitor token signed for subject. With no subject, there's no token, and the agent answers with its public tools only, which is right for a signed-out visitor.

claims become $auth.claims in your tools. aud, sub, exp and the other registered claims are always set by the gem, never taken from claims. Add metadata to show a profile link and badges to whoever answers in the inbox; see Sessions and channels.

Vatio::Identity.token_for(subject:, claims:, expires_in:) returns the bare token, for the browser SDK or window.VatioWidget.identify(token).

Caching

Rails renders the tag per request, so it is safe by default. If a page cache, a CDN or caches_action stores it, the next visitor gets this one's identity. On a cached page, render the tag without a token and call window.VatioWidget.identify(token) from an uncached endpoint.

#Sign in from WhatsApp and Instagram

A WhatsApp or Instagram chat has no page to put a token on, so when a private tool needs one, Vatio sends the contact a Sign in button. It opens auth.sign_in_url with a code; sign them in the way your site already does and redirect:

class VatioSignInsController < ApplicationController
  before_action :authenticate_user!   # your own login

  def show
    # Back from a Turbo login form: fetch cannot follow a redirect to vatio.ai.
    if request.headers["X-Turbo-Request-Id"]
      return render html: helpers.tag.meta(name: "turbo-visit-control", content: "reload")
    end

    redirect_to Vatio::Identity.sign_in_redirect_url(
      code: params[:code],
      subject: current_user.id,
      claims: { name: current_user.name, phone_number: current_user.phone }
    ), allow_other_host: true
  end
end

The token lasts a week by default (expires_in: changes it). The host is fixed to vatio.ai, never taken from the request, and a code that is not a Vatio sign-in code raises ArgumentError.

The first lines matter when the contact was not signed in yet: your login form redirects back here once they are, and Turbo submits that form with fetch, which cannot follow the next redirect to another site. The login page just sits there. Answering Turbo with a reload makes the browser load this page for real, and that redirect works. See On WhatsApp and Instagram.

#Verify the token in your API

A private tool forwards the same JWT to your endpoint as Authorization: Bearer. Include the concern and scope by vatio_subject:

class Api::BookingsController < ApplicationController
  include Vatio::Identity::Authentication
  before_action :authenticate_vatio!   # 401 unless the token verifies

  def index
    render json: Booking.where(user_id: vatio_subject)
  end
end

The check pins the algorithm (never read from the token's header) and requires sub, exp and an aud equal to your slug, with 60 seconds of leeway. That is the same check Vatio runs.

MethodReturns
vatio_subjectThe user id from sub, as a string, or nil
vatio_principalsubject, claims (without registered claims) and token, or nil
vatio_signed_in?Whether the token verified
authenticate_vatio!Renders {"error":"unauthorized"} with a 401 unless signed in

Outside a controller, Vatio::Identity.verify(token) returns the same principal, or nil for anything that does not verify. nil is not an error: it means signed out.

#Several workspaces

configure sets one workspace for the whole app. An app that serves several, each customer with their own Vatio workspace and key, builds a Config per workspace and passes it as config::

config = Vatio::Identity::Config.new(audience: tenant.vatio_slug, public_key: tenant.vatio_public_key)

Vatio::Identity.verify(token, config: config)

token_for, widget_tag and sign_in_redirect_url take config: too. They sign, so their config needs private_key:. In a controller, override vatio_identity_config to choose the workspace; returning nil for a tenant without Vatio answers 401:

class Api::BookingsController < ApplicationController
  include Vatio::Identity::Authentication
  before_action :authenticate_vatio!

  private

  def vatio_identity_config
    return if current_tenant.vatio_public_key.blank?

    Vatio::Identity::Config.new(audience: current_tenant.vatio_slug, public_key: current_tenant.vatio_public_key)
  end
end

Choose the tenant from the request (the host, the path), never from the token's own aud: the token is what is being checked.

#Message people first

require "vatio/server" adds a client for the Messages API: the agent writes to someone first on WhatsApp (an order that shipped, an appointment tomorrow) and keeps answering when they reply.

require "vatio/server"

Vatio::Server.configure do |c|
  c.server_key     = ENV.fetch("VATIO_SERVER_KEY")   # vsk_..., from `vatio keys create`
  c.workspace      = "acme"                           # defaults to Vatio::Identity's audience
  c.webhook_secret = ENV["VATIO_WEBHOOK_SECRET"]      # whsec_..., for the receiver
end

message = Vatio::Server.send_message(
  to: "+56912345678",
  brief: "Order #1042 shipped today with Starken, tracking 99812. Offer to send the tracking link.",
  external_ref: "order-1042-shipped",
  subject: user.id   # signs an identity, so private tools run when they answer
)
message.queued?   # => true

Create the key with vatio keys create and keep it on your server: it can message your customers.

MethodPurpose
send_message(to:, brief:/text:/template:, external_ref:, ...)Queue a message. Returns a Message
message_status(id)Read one back, with content once sent
messages(external_ref:, status:, limit:, before:)List them, newest first
templatesThe approved templates, each with with_params(...)
identity_for(subject:, claims:, ttl:)Sign an identity once, to reuse on a retry

brief has the agent write the message; text is sent as is; template is what goes out when the 24-hour window is closed. idempotency_key defaults to external_ref, so a retry is a replay and never a second message. A subject: identity lasts 48 hours (identity_ttl: changes it).

For another workspace's key, build a client: Vatio::Server::Client.new(server_key:, workspace:, identity: config). The identity: config signs subject: with that workspace's key.

#Errors

Every refusal raises a Vatio::Server::Error with code, message, status, details and request_id:

ClassWhen
OutsideWindowNo message in 24 hours and no template sent; last_inbound_at
OptedOutThey asked not to be written to
HumanAnsweringA person has the conversation
TemplateErrorTemplate missing, not approved, or wrong parameters
InvalidRequestAny other 422
UnauthorizedThe key is wrong, revoked, or another workspace's
ConflictAny other 409, such as idempotency_conflict
NotFoundmessage_status for an id this key cannot see
RateLimitedMore than 60 a minute on one key; retryable?
Unavailable5xx, timeouts, refused connections; retryable?

The full list of codes is in Messages.

#Background jobs

Where ActiveJob is loaded, Vatio::Server::SendMessageJob.perform_later takes the same keywords. It retries RateLimited and Unavailable with backoff, ten attempts over about four hours, and logs and discards the rest. Subclass it and discard_on an error to act on it. The job uses the global configuration.

#Webhooks

class VatioWebhooksController < ActionController::API
  include Vatio::Server::WebhookReceiver

  on_vatio_event "message.replied" do |event|
    message = Vatio::Server::Message.from(event["data"]["message"])
    Order.find_by(ref: message.external_ref)&.update!(customer_replied_at: message.replied_at)
  end
end

The receiver checks Vatio-Signature against the raw body, refuses a delivery more than five minutes old, and answers 400 to anything that does not verify. Delivery is at least once, so dedupe on event["id"]. Outside Rails, Vatio::Server::Webhook.verify!(payload:, signature:, secret:).

#Tests

require "vatio/server/testing"

setup    { Vatio::Server::Testing.fake! }
teardown { Vatio::Server::Testing.real! }

fake! swaps the network for an in-memory API that answers as the real one does, through the same error mapping. Testing.messages is what was sent, fail_next!(:outside_window) forces a refusal, and webhook("message.replied", ...) builds a signed request for your receiver. With RSpec, have_sent_message(to:, brief:).

#What it does not do

It does not rotate keys or cache tokens: every request verifies again, so an expired token is caught. vatio/server does not retry on its own; retries belong to SendMessageJob, or to whoever knows whether the message still makes sense.

Bugs and questions go to github.com/vatio-ai/ruby/issues.

Last updated:

Latest CLI version: 0.79.0 · Changelog · The whole contract as Markdown: /docs/docs.md · or vatio docs