#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:
| Require | What it does | Talks to Vatio? |
|---|---|---|
vatio/identity | Signs the JWT that tells your agent who the user is, and verifies it when a private tool calls your API | Never: signing and verifying are local |
vatio/server | A client for the server API, so the agent writes to someone first on WhatsApp, plus the receiver for its webhooks | Yes, 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
| Setting | Default | |
|---|---|---|
audience | none, required | Your workspace slug. Vatio refuses a token whose aud is not the slug |
private_key | none | The PEM that signs. A public key here is refused |
public_key | the private key | Only to verify without the private key |
algorithm | from the key | ES256/ES384/ES512 from the EC curve, RS256 for RSA. Must match auth.algorithm in vatio.yml |
expires_in | 3600 | Seconds 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.
| Method | Returns |
|---|---|
vatio_subject | The user id from sub, as a string, or nil |
vatio_principal | subject, 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.
| Method | Purpose |
|---|---|
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 |
templates | The 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:
| Class | When |
|---|---|
OutsideWindow | No message in 24 hours and no template sent; last_inbound_at |
OptedOut | They asked not to be written to |
HumanAnswering | A person has the conversation |
TemplateError | Template missing, not approved, or wrong parameters |
InvalidRequest | Any other 422 |
Unauthorized | The key is wrong, revoked, or another workspace's |
Conflict | Any other 409, such as idempotency_conflict |
NotFound | message_status for an id this key cannot see |
RateLimited | More than 60 a minute on one key; retryable? |
Unavailable | 5xx, 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.