diff --git a/app/controllers/api_controller.rb b/app/controllers/api_controller.rb
index 2d5bf65..b578d15 100644
--- a/app/controllers/api_controller.rb
+++ b/app/controllers/api_controller.rb
@@ -1,27 +1,82 @@
 class ApiController < ApplicationController
+  rescue_from ActiveRecord::RecordNotFound, with: :record_not_found
+  rescue_from ActionController::ParameterMissing, with: :parameter_missing
+
   # This a private trusted API
   skip_before_action :verify_authenticity_token
 
   before_action :authenticate_token
+  before_action :domain, only: [:list, :bulk]
 
   # GET /ping
   def ping
     render json: { ok: true, response: :pong }
   end
 
   # GET /whoami
   def  whoami
     render json: { ok: true, response: current_user.to_api }
   end
 
+  # GET domain/<name>/list
+  def list
+    records = Record.smart_order(@domain.records).map(&:to_api)
+    render json: { ok: true, response: records }
+  end
+
+  # POST domain/<name>/list
+  def bulk
+    api_params = params.require(:api).permit!
+    ops, err, bulk_ops = domain.api_bulk(api_params)
+
+    if err.empty?
+      notify_record_bulk(domain, bulk_ops)
+
+      render json: { ok: true,
+                     response: {
+                       operations: ops
+                     }
+                   }
+    else
+      render json: { ok: false,
+                     errors: err,
+                     response: {
+                       operations: ops
+                     }
+                   }
+    end
+  end
+
   private
 
   def authenticate_token
     if user = User.find_by_token(params.require(:token))
       warden.set_user(user, store: false)
     else
       head(403)
     end
   end
 
+  def domain
+    if params[:domain] =~ /^[0-9]+$/
+      params[:domain_id] = params[:domain]
+    else
+      params[:domain_id] = Domain.find_by_name!(params[:domain]).id
+    end
+
+    super
+  end
+
+  def record_not_found
+    render json: { ok: false, error: :record_not_found }
+  end
+
+  def parameter_missing
+    render json: { ok: false, error: :parameter_missing }
+  end
+
+  def notify_record_bulk(*args)
+    notification.notify_record_bulk(current_user, *args) if WebDNS.settings[:notifications]
+  end
+
 end
diff --git a/app/models/domain.rb b/app/models/domain.rb
index edad604..2894121 100644
--- a/app/models/domain.rb
+++ b/app/models/domain.rb
@@ -1,321 +1,384 @@
 class Domain < ActiveRecord::Base
   class NotAChild < StandardError; end
   self.inheritance_column = :nx
 
   # List all supported domain types.
   def self.domain_types
     [
       'NATIVE',
       'MASTER',
       'SLAVE',
     ]
   end
 
   # List domain types that can be created.
   def self.allowed_domain_types
     domain_types - WebDNS.settings[:prohibit_domain_types]
   end
 
   # List parent authorities
   def self.dnssec_parent_authorities
     WebDNS.settings[:dnssec_parent_authorities].keys.map(&:to_s)
   end
 
   # Fire event after transaction commmit
   # Changing state inside a hook messes things up,
   # this trick handles that
   attr_accessor :fire_event
 
   belongs_to :group
   has_many :jobs
   has_many :records
   # BUG in bump_serial_trigger
   has_one :soa, -> { unscope(where: :type).where(type: 'soa') }, class_name: SOA
   belongs_to :dnssec_policy
 
   validates :group_id, presence: true
   validates :name, uniqueness: true, presence: true
   validates :type, presence: true, inclusion: { in: domain_types }
   validates :master, presence: true, ipv4: true, if: :slave?
 
   validates :dnssec, inclusion: { in: [false] }, unless: :dnssec_elegible?
   validates :dnssec_parent_authority, inclusion: { in: dnssec_parent_authorities }, if: :dnssec?
   validates :dnssec_parent, hostname: true, if: :dnssec?
   validates :dnssec_policy_id, presence: true, if: :dnssec?
 
   after_create :generate_soa
   after_create :generate_ns
 
   after_create :install
   before_save :check_convert
   before_save :check_dnssec_parent_authority, if: :dnssec?
   after_commit :after_commit_event
 
   attr_writer :serial_strategy
 
   def self.dnssec_progress(current_state)
     progress = [
       :pending_signing, # 1/3
       :wait_for_ready,  # 2/3
       :pending_ds]      # 3/3
     idx = progress.index(current_state.to_sym)
     return if idx.nil?
 
     [idx+1, progress.size].join('/')
   end
 
   state_machine initial: :initial do
     after_transition(any => :pending_install) { |domain, _t| Job.add_domain(domain) }
     after_transition(any => :pending_remove) { |domain, _t| Job.shutdown_domain(domain) }
     after_transition(any => :pending_ds_removal) { |domain, _t| Job.dnssec_drop_ds(domain) }
     after_transition(any => :pending_signing) { |domain, _t| Job.dnssec_sign(domain) }
     after_transition(any => :wait_for_ready) { |domain, _t| Job.wait_for_ready(domain) }
     after_transition(any => :pending_ds) { |domain, t| Job.dnssec_push_ds(domain, *t.args) }
     after_transition(any => :pending_ds_rollover) { |domain, t| Job.dnssec_rollover_ds(domain, *t.args) }
     after_transition(any => :pending_plain) { |domain, _t| Job.convert_to_plain(domain) }
     after_transition(any => :destroy) { |domain, _t| domain.destroy }
 
     # User events
     event :install do
       transition initial: :pending_install
     end
 
     event :dnssec_sign do
       transition operational: :pending_signing
     end
 
     event :signed do
       transition pending_signing: :wait_for_ready
     end
 
     event :push_ds do
       transition wait_for_ready: :pending_ds, operational: :pending_ds_rollover
     end
 
     event :plain_convert do
       transition operational: :pending_plain
     end
 
     event :remove do
       transition [:operational, :pending_ds_removal] => :pending_remove
     end
 
     event :full_remove do
       transition operational: :pending_ds_removal
     end
 
     # Machine events
     event :installed do
       transition pending_install: :operational
     end
 
     event :converted do
       transition [:pending_ds, :pending_plain] => :operational
     end
 
     event :complete_rollover do
       transition pending_ds_rollover: :operational
     end
 
     event :cleaned_up do
       transition pending_remove: :destroy
     end
 
     event :ksk_rollover_detected do
       transition operational: :ksk_rollover
     end
   end
 
   # Returns true if this domain is elegigble for DNSSEC
   def dnssec_elegible?
     return false if slave?
 
     true
   end
 
   # Returns the zone serial if a SOA record exists
   def serial
     return if !soa
 
     soa.serial
   end
 
   # Get the zone's serial strategy.
   #
   # Returns one of the supported serial strategies.
   def serial_strategy
     @serial_strategy ||= WebDNS.settings[:serial_strategy]
   end
 
   # Returns true if this a reverse zone.
   def reverse?
     name.end_with?('.in-addr.arpa') || name.end_with?('.ip6.arpa')
   end
 
   # Returns true if this a ENUM zone.
   def enum?
     name.end_with?('.e164.arpa')
   end
 
   # Returns true if this is a slave zone.
   def slave?
     type == 'SLAVE'
   end
 
   def to_export
     Hash[
       :id, id,
       :name, name,
       :group, group.name,
     ].with_indifferent_access
   end
 
   # Compute subnet for reverse records
   def subnet
     return if not reverse?
 
     if name.end_with?('.in-addr.arpa')
       subnet_v4
     elsif name.end_with?('.ip6.arpa')
       subnet_v6
     end
   end
 
   def self.replace_ds(parent, child, records)
     records ||= []
     parent = find_by_name!(parent)
     fail NotAChild if not child.end_with?(parent.name)
 
     existing = parent.records.where(name: child, type: 'DS')
     recs = records.map { |rec| DS.new(domain: parent, name: child, content: rec) }
 
     ActiveRecord::Base.transaction do
       existing.destroy_all
       recs.map(&:save!)
     end
   end
 
+  # Apply api bulk to operations to the zone
+  #
+  # 1) Deletions
+  # 2) Upserts
+  # 3) Additions
+  def api_bulk(opts)
+    api_deletes = opts[:deletes] || []
+    api_upserts = opts[:upserts] || []
+    api_additions = opts[:additions] || []
+    api_delete_errors = {}
+
+    deletes = []
+    additions = {}
+
+    api_deletes.each { |del|
+      rec = records.find_by(del)
+      # Fail-fast if record doesn't exist
+      if rec.nil?
+        return [{}, { deletes: { del: 'record not found'}}]
+      end
+
+      deletes << rec.id
+    }
+
+    # We delete records matching the same name & type
+    api_upserts.each { |ups|
+      query = ups.slice(:name, :type)
+      existing = records.where(query).to_a
+
+      # Skip upsert if we are trying to save the same record
+      next if existing.one? && ups.all? { |k, v| existing.first.to_api[k] == v }
+
+      deletes += existing.map(&:id)
+      api_additions << ups
+    }
+
+    api_additions.each { |add|
+      additions[add] = add
+    }
+
+    ops, errors = bulk(deletes: deletes, additions: additions)
+
+    # Serialize the response for API
+    api_ops = {}
+    api_errors = {}
+
+    # ops
+    ops.each { |op, recs| api_ops[op] = recs.map(&:to_api) }
+
+    # errors
+    if errors.any?
+      errors.each { |op, err|
+        api_errors[op] = err.map { |rec, err|
+          { operation: rec, error: err }
+        }
+      }
+    end
+
+    # This is a bit ugly, we return an ops hash with the original bulk
+    # responses so we can feed it to record notification.
+    [api_ops, api_errors, ops]
+  end
+
   # Apply bulk to operations to the zones
   #
   # 1) Deletions
   # 2) Changes
   # 3) Additions
   def bulk(opts)
     deletes = opts[:deletes] || []
     changes = opts[:changes] || {}
     additions = opts[:additions] || {}
     errors = Hash.new { |h, k| h[k] = {} }
     operations = Hash.new { |h, k| h[k] = [] }
 
     ActiveRecord::Base.transaction do
       # Deletes
       to_delete = records.where(id: deletes).index_by(&:id)
       deletes.each { |rec_id|
         if rec = to_delete[Integer(rec_id)]
           rec.destroy
           operations[:deletes] << rec
           next
         end
 
         errors[:deletes][rec_id] = 'Deleted record not found'
       }
 
       # Changes
       to_change = records.where(id: changes.keys).index_by(&:id)
       changes.each {|rec_id, changes|
         if rec = to_change[Integer(rec_id)]
           operations[:changes] << rec
           errors[:changes][rec_id] = rec.errors.full_messages.join(', ') if !rec.update(changes)
           next
         end
 
         errors[:changes][rec_id] = 'Changed record not found'
       }
 
       # Additions
       additions.each { |inc, attrs|
         rec = records.new(attrs)
         operations[:additions] << rec
         errors[:additions][inc] = rec.errors.full_messages.join(', ') if !rec.save
       }
 
       raise ActiveRecord::Rollback if errors.any?
     end
 
     [operations, errors]
   end
 
   private
 
   def subnet_v4
     # get ip octets (remove .in-addr.arpa)
     octets = name.split('.')[0...-2].reverse
     return if octets.any? { |_| false }
 
     mask = 8 * octets.size
     octets += [0, 0, 0, 0]
 
     ip = IPAddr.new octets[0, 4].join('.')
 
     [ip, mask].join('/')
   end
 
   def subnet_v6
     nibbles = name.split('.')[0...-2].reverse
     return if nibbles.any? { |_| false }
 
     mask = 4 * nibbles.size
     nibbles += [0] * 32
 
     ip = IPAddr.new nibbles[0, 32].in_groups_of(4).map(&:join).join(':')
 
     [ip, mask].join('/')
   end
 
   # Hooks
 
   def generate_soa
     soa_record = SOA.new(domain: self)
 
     soa_record.save!
   end
 
   def generate_ns
     return if slave?
     return if WebDNS.settings[:default_ns].empty?
 
     WebDNS.settings[:default_ns].each { |ns|
       Record.find_or_create_by!(domain: self, type: 'NS', name: '', content: ns)
     }
   end
 
   def check_convert
     return if !dnssec_changed?
 
     event = dnssec ? :dnssec_sign : :plain_convert
     if state_events.include?(event)
       self.fire_event = event # Schedule event for after commit
       return true
     end
 
     errors.add(:dnssec, 'You cannot modify dnssec settings in this state!')
     false
   end
 
   def check_dnssec_parent_authority
     cfg = WebDNS.settings[:dnssec_parent_authorities][dnssec_parent_authority.to_sym]
     return if !cfg[:valid]
 
     return true if cfg[:valid].call(dnssec_parent)
 
     errors.add(:dnssec_parent_authority, 'Parent zone is not accepted for the selected parent authority!')
     false
   end
 
   def after_commit_event
     return if !fire_event
 
     fire_state_event(fire_event)
     self.fire_event = nil
   end
 end
diff --git a/app/models/record.rb b/app/models/record.rb
index c29fa47..228e4f9 100644
--- a/app/models/record.rb
+++ b/app/models/record.rb
@@ -1,311 +1,322 @@
 require 'ipaddr'
 require_dependency 'drop_privileges_validator'
 
 class Record < ActiveRecord::Base
   belongs_to :domain
   # Powerdns inserts empty records on slave zones,
   # we want to hide them
   #
   # http://mailman.powerdns.com/pipermail/pdns-users/2013-December/010389.html
   default_scope { where.not(type: nil) }
 
   # List all supported DNS RR types.
   def self.record_types
     [
       'A', 'AAAA', 'CNAME',
       'MX',
       'TXT', 'SPF', 'SRV', 'SSHFP',
       'SOA', 'NS',
       'PTR', 'NAPTR',
       'DS'
     ]
   end
 
   # List types usually used in forward zones.
   def self.forward_records
     record_types - ['SOA', 'PTR']
   end
 
   # List types usually used in reverse zones.
   def self.reverse_records
     ['PTR', 'CNAME', 'TXT', 'NS', 'NAPTR', 'DS']
   end
 
   # List types usually used in enum zones.
   def self.enum_records
     ['NAPTR', 'CNAME', 'TXT', 'NS', 'DS']
   end
 
   # List types that can be touched by a simple user.
   def self.allowed_record_types
     record_types - WebDNS.settings[:prohibit_records_types]
   end
 
   validates :name, presence: true
   validates :type, inclusion: { in: record_types }
 
   # http://mark.lindsey.name/2009/03/never-use-dns-ttl-of-zero-0.html
   validates_numericality_of :ttl,
                             allow_nil: true, # Default pdns TTL
                             only_integer: true,
                             greater_than: 0,
                             less_than_or_equal_to: 2_147_483_647
 
   # Don't allow the following actions on drop privileges mode
   validate :no_touching_for_slave_zones, if: -> { domain.slave? }
 
   validates_drop_privileges :type,
                             message: 'You cannot touch that record!',
                             unless: -> { Record.allowed_record_types.include?(type) }
   validates_drop_privileges :name,
                             message: 'You cannot touch top level NS records!',
                             if: -> { type == 'NS' && domain_record? }
 
   before_validation :guess_reverse_name
   before_validation :set_name
   after_save :update_zone_serial
   after_destroy :update_zone_serial
 
   before_create :generate_classless_delegations, unless: -> { domain.slave? }
   before_destroy :delete_classless_delegations, unless: -> { domain.slave? }
 
   # Smart sort a list of records.
   #
   # Order by:
   # * Top level records
   # * Record name
   # * SOA
   # * NS
   # * Friendly type
   # * Priority
   # * Content
   #
   # records - The list of records to order.
   #
   # Returns the list sorted.
   def self.smart_order(records)
     records.sort_by { |r|
       [
         r.domain_record? ? 0 : 1,   # Zone records
         r.classless_delegated? ? 1 : 0,
         r.name,
         r.type == 'SOA' ? 0 : 1,
         r.type == 'NS' ? 0 : 1,
         record_types.index(r.type), # Friendly type
         r.prio,
         r.content
       ]
     }
   end
 
   def self.search(query)
     wild_search = "%#{query}%" # !index_friendly
 
     where('name like :q or content like :q', q: wild_search)
   end
 
   # Get the a short name for the record (without the zone suffix).
   #
   # Returns a string.
   def short
     return '' if name == domain.name
     return '' if name.blank?
 
     File.basename(name, ".#{domain.name}")
   end
 
   # Returns true if this is a zone record.
   def domain_record?
     name.blank? || name == domain.name
   end
 
   # Find out if the record is edittable.
   #
   # by - Editable by :user or :admin.
   #
   # Returns true if the record is editable.
   def editable?(by = :user)
     return false if domain.slave?
     return false if classless_delegated?
 
     case by
     when :user
       return false unless Record.allowed_record_types.include?(type)
       return false if type == 'NS' && domain_record?
     end
 
     true
   end
 
   # Find out this record type supports priorities.
   #
   # We set this to false by default, record types that support priorities.
   # shoule override this.
   #
   # Returns true this record type support priorities.
   def supports_prio?
     false
   end
 
   # Make sure rails generates record specific urls for all record types.
   #
   # Overrides default rails STI behavior.
   def self.model_name
     return super if self == Record
 
     Record.model_name
   end
 
   # Generate the usual admin friendly DNS record line.
   #
   # Returns a string.
   def to_dns
     [name, ttl, 'IN', type, supports_prio? ? prio : nil, content].compact.join(' ')
   end
 
   # Generate a shorter version of the DNS record line.
   #
   # Returns a string.
   def to_short_dns
     [name, 'IN', type].join(' ')
   end
 
+  def to_api
+    Hash[
+      :name, name,
+      :content, content,
+      :type, type,
+      :ttl, ttl,
+      :prio, prio,
+      :disabled, disabled
+    ].with_indifferent_access
+  end
+
   def classless_delegated?
     return false if not type == 'CNAME'
     return false if not domain.name.end_with?('.in-addr.arpa')
 
     network, mask = parse_delegation(content)
     return false if network.nil?
 
     octet = name.split('.').first.to_i
     return true if octet >= network
     return true if octet <= network + 2 ^ (32 - mask) - 1 # max
 
     false
   end
 
   def classless_delegation?
     return true if classless_delegation
 
     false
   end
 
   def as_bulky_json
     Hash[
       id: id,
       name: name,
       type: type,
       ttl: ttl,
       prio: prio,
       content: content,
       disabled: disabled
     ]
   end
   private
 
   # Validations
 
   def no_touching_for_slave_zones
     # Allow automatic SOA creation for slave zones
     # powerdns needs a valid serial to compare it with master
     return if type == 'SOA' && validation_context == :create
 
     errors.add(:type, 'This is a slave zone!')
   end
 
   # Hooks
 
   def guess_reverse_name
     return if not type == 'PTR'
     return if not domain.reverse?
     return if name.blank?
 
     reverse = IPAddr.new(name).reverse
     self.name = reverse if reverse.end_with?(domain.name)
   rescue IPAddr::InvalidAddressError # rubycop:disable HandleExceptions
   end
 
   # Powerdns expects full domain names
   def set_name
     self.name = domain.name if name.blank?
     self.name = "#{name}.#{domain.name}" if not name.end_with?(domain.name)
   end
 
   def remove_terminating_dot
     self.content = content.gsub(/\.+\Z/, '')
   end
 
   def update_zone_serial
     # SOA records handle serial themselves
     return true if type == 'SOA'
     return true if !domain
 
     domain.soa.bump_serial!
   end
 
   def classless_delegation
     return if not type == 'NS'
     return if not domain.name.end_with?('.in-addr.arpa')
 
     network, mask = parse_delegation(name)
     return if network.nil?
 
     range = IPAddr.new("0.0.0.#{network}/#{mask}").to_range
     return if !range.first.to_s.end_with?(".#{network}")
 
     range.map { |ip|
       octet = ip.to_s.split('.').last
       "#{octet}.#{domain.name}"
     }
   end
 
   def parse_delegation(value)
     first, _rest = value.split('.', 2)
     first.gsub!('-', '/')
     return if !first['/']
 
     network, mask = first.split('/', 2).map { |i| Integer(i).abs }
     return if [network, mask].join('/') != first
     return if mask <= 24
     return if mask > 31
     return if network > 255
 
     [network, mask]
   rescue ArgumentError # Not an integer
   end
 
   def delete_classless_delegations
     rnames = classless_delegation
     return unless rnames
 
     # Check if we have another NS for the same delegation
     return if domain.records.where(type: 'NS', name: name)
                .where.not(id: id).exists?
 
     # Delete all CNAMEs
     domain.records.where(name: rnames,
                          type: 'CNAME',
                          content: name).delete_all
   end
 
   def generate_classless_delegations
     rnames = classless_delegation
     return unless rnames
 
     # Make sure no record exists for a delegated domain
     if domain.records.where(name: rnames)
         .where.not(content: name).exists?
 
       errors.add(:name, 'Records already exist for the delegated octets!')
       return false
     end
 
     rnames.each { |rname|
       CNAME.find_or_create_by!(
         domain: domain,
         name: rname,
         content: name
       )
     }
   end
 end
diff --git a/config/routes.rb b/config/routes.rb
index 0ecff57..80b5e24 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -1,75 +1,77 @@
 Rails.application.routes.draw do
   # Override devise user removal
   devise_scope :users do
     delete :users, to: redirect('/')
   end
   devise_for :users
   get '/auth/saml', to: 'auth#saml'
 
   root to: redirect('/domains')
 
   resources :users, only: [] do
     get :token, to: 'users#token', on: :member
     post :generate_token, to: 'users#generate_token', on: :member
   end
 
   resources :groups, only: [:show] do
     get :search_member,
         to: 'groups#search_member', on: :member
     post :members,
          to: 'groups#create_member', as: :create_member, on: :member
     delete 'member/:user_id',
            to: 'groups#destroy_member', as: :destroy_member, on: :member
   end
 
   resources :domains do
     get :edit_dnssec, to: 'domains#edit_dnssec', on: :member
     delete :full_destroy, to: 'domains#full_destroy', on: :member
 
     resources :records, except: [:index, :show] do
       # Reuse records#update instead of introducing new controller actions
       #
       # rubocop:disable Style/AlignHash
       put :disable, to: 'records#update', on: :member,
           defaults: { record: { disabled: true } }
       put :enable,  to: 'records#update', on: :member,
           defaults: { record: { disabled: false } }
 
       put :editable, to: 'records#editable', on: :collection
       post :valid, to: 'records#valid', on: :collection
       post :bulk, to: 'records#bulk', on: :collection
       # rubocop:enable Style/AlignHash
     end
   end
 
   get '/records/search', to: 'records#search'
 
   # Admin
   namespace :admin do
     root to: redirect('/admin/groups')
 
     resources :groups, except: [:show]
     resources :jobs, only: [:index, :destroy] do
       put :done, to: 'jobs#update', on: :member,
           defaults: { job: { status: 1 } }
       put :pending,  to: 'jobs#update', on: :member,
           defaults: { job: { status: 0 } }
     end
     resources :users, only: [:destroy] do
       get :orphans, to: 'users#orphans', on: :collection
       put :update_groups, to: 'users#update_groups', on: :collection
     end
   end
 
   # API
   scope '/api' do
     get :ping, to: 'api#ping'
     get :whoami, to: 'api#whoami'
+    get '/domain/:domain/list', to: 'api#list', constraints: { domain: /[^\/]+/}
+    post '/domain/:domain/bulk', to: 'api#bulk', constraints: { domain: /[^\/]+/}
   end if WebDNS.settings[:api]
 
   # Private
   put 'private/replace_ds', to: 'private#replace_ds'
   put 'private/trigger_event', to: 'private#trigger_event'
   get 'private/zones', to: 'private#zones'
 end
 
diff --git a/test/models/domain_test.rb b/test/models/domain_test.rb
index b91ea82..fce86b1 100644
--- a/test/models/domain_test.rb
+++ b/test/models/domain_test.rb
@@ -1,308 +1,414 @@
 require 'test_helper'
 
 class DomainTest < ActiveSupport::TestCase
   def setup
     @domain = build(:domain)
   end
 
   test 'automatic SOA creation' do
     @domain.save!
     @domain.reload
     assert_not_nil @domain.soa
   end
 
   test 'increment serial on new record' do
     @domain.save!
     soa = @domain.soa
 
     assert_serial_update soa do
       www = A.new(name: 'www', domain: @domain, content: '1.2.3.4')
       www.save!
     end
   end
 
   test 'increment serial on record update' do
     @domain.save!
     www = A.new(name: 'www', domain: @domain, content: '1.2.3.4')
     www.save!
     soa = @domain.soa.reload
 
     assert_serial_update soa do
       www.content = '1.2.3.5'
       www.save!
     end
   end
 
   test 'automatic NS creation' do
     @domain.save!
     @domain.reload
     assert_equal WebDNS.settings[:default_ns].sort,
                  @domain.records.where(type: 'NS').pluck(:content).sort
   end
 
   test 'increment serial on record destroy' do
     @domain.save!
     www = A.new(name: 'www', domain: @domain, content: '1.2.3.4')
     www.save!
     soa = @domain.soa.reload
 
     assert_serial_update soa do
       www.destroy!
     end
   end
 
   class SlaveDomainTest < ActiveSupport::TestCase
     def setup
       @domain = build(:slave)
     end
 
     test 'saves' do
       @domain.save
 
       assert_empty @domain.errors
     end
 
     test 'automatic SOA creation' do
       @domain.save!
       @domain.reload
       assert_not_nil @domain.soa
       assert_equal 1, @domain.soa.serial
     end
 
     test 'validates master' do
       @domain.master = 'not-an-ip'
       @domain.save
 
       assert_not_empty @domain.errors['master']
     end
 
     test 'no records are allowed for users' do
       @domain.save!
       rec = build(:a, domain_id: @domain.id)
 
       assert_not rec.valid?
       assert_not_empty rec.errors[:type]
     end
   end
 
   class StatesDomainTest < ActiveSupport::TestCase
     def setup
       @domain = build(:domain)
       @policy = create(:dnssec_policy)
     end
 
     test 'domain lifetime' do
       assert_equal 'initial', @domain.state
 
       # Create
       assert_jobs do
         @domain.save! # user triggered
         assert_equal 'pending_install', @domain.state
       end
       @domain.installed # job triggered
       assert_equal 'operational', @domain.state
 
       # Convert to dnssec (sign)
       assert_jobs do
         @domain.dnssec = true
         @domain.dnssec_policy = @policy
         @domain.dnssec_parent = @domain.name.split('.', 2).last
         @domain.dnssec_parent_authority = 'test_authority'
         @domain.save!
 
         # After commit is not triggered in tests,
         # so we have to trigger it manually
         @domain.send(:after_commit_event)
 
         assert_equal 'pending_signing', @domain.state
       end
 
       assert_jobs do
         assert @domain.signed # job triggered
         assert_equal 'wait_for_ready', @domain.state
       end
 
       # Convert to dnssec (publish ds)
       assert_jobs do
         assert @domain.push_ds(['dss1', 'dss2']) # triggered by ds-schedule script
         assert_equal 'pending_ds', @domain.state
       end
       assert @domain.converted # job triggered
       assert_equal 'operational', @domain.state
 
       # KSK rollover
       assert_jobs do
         assert @domain.push_ds(['dss3', 'dss4']) # triggered by ds-schedule script
         assert_equal 'pending_ds_rollover', @domain.state
       end
       assert @domain.complete_rollover # job triggered
       assert_equal 'operational', @domain.state
 
       # Convert to plain
       assert_jobs do
         assert @domain.plain_convert # user triggered
         assert_equal 'pending_plain', @domain.state
       end
       assert @domain.converted # job triggered
       assert_equal 'operational', @domain.state
 
       # Remove
       assert_jobs do
         assert @domain.remove # user triggered
         assert_equal 'pending_remove', @domain.state
       end
       assert @domain.cleaned_up # job triggered
       assert_equal 'destroy', @domain.state
     end
 
     test 'domain lifetime #full-destroy' do
       assert_equal 'initial', @domain.state
 
       # Create
       assert_jobs do
         @domain.save! # user triggered
         assert_equal 'pending_install', @domain.state
       end
       @domain.installed # job triggered
       assert_equal 'operational', @domain.state
 
       # Convert to dnssec (sign)
       assert_jobs do
         @domain.dnssec = true
         @domain.dnssec_policy = @policy
         @domain.dnssec_parent = @domain.name.split('.', 2).last
         @domain.dnssec_parent_authority = 'test_authority'
         @domain.save!
 
         # After commit is not triggered in tests,
         # so we have to trigger it manually
         @domain.send(:after_commit_event)
 
         assert_equal 'pending_signing', @domain.state
       end
 
       assert_jobs do
         assert @domain.signed # job triggered
         assert_equal 'wait_for_ready', @domain.state
       end
 
       # Convert to dnssec (publish ds)
       assert_jobs do
         assert @domain.push_ds(['dss1', 'dss2']) # triggered by ds-schedule script
         assert_equal 'pending_ds', @domain.state
       end
       assert @domain.converted # job triggered
       assert_equal 'operational', @domain.state
 
       # KSK rollover
       assert_jobs do
         assert @domain.push_ds(['dss3', 'dss4']) # triggered by ds-schedule script
         assert_equal 'pending_ds_rollover', @domain.state
       end
       assert @domain.complete_rollover # job triggered
       assert_equal 'operational', @domain.state
 
       # Full Remove (Drops DS records)
       assert_jobs do
         assert @domain.full_remove # user triggered
         assert_equal 'pending_ds_removal', @domain.state
       end
 
       assert_jobs do
         assert @domain.remove # job triggered
         assert_equal 'pending_remove', @domain.state
       end
       assert @domain.cleaned_up # job triggered
       assert_equal 'destroy', @domain.state
     end
   end
 
   class DsDomainTest < ActiveSupport::TestCase
     def setup
       @domain = create(:domain)
       @ds = [
         '31406 8 1 189968811e6eba862dd6c209f75623d8d9ed9142',
         '31406 8 2 f78cf3344f72137235098ecbbd08947c2c9001c7f6a085a17f518b5d8f6b916d',
       ]
       @child = "dnssec.#{@domain.name}"
       @extra = DS.create(domain: @domain, name: @child, content: 'other')
     end
 
     test 'add ds records' do
       Domain.replace_ds(@domain.name, @child, @ds)
       @extra.save! # Should be deleted
 
       assert_equal @ds.size, DS.where(name: "dnssec.#{@domain.name}").count
       @ds.each { |ds|
         assert_equal 1, DS.where(name: "dnssec.#{@domain.name}", content: ds).count
       }
     end
 
     test 'remove ds records' do
       Domain.replace_ds(@domain.name, @child, [])
 
       assert_equal 0, DS.where(name: "dnssec.#{@domain.name}").count
     end
 
     test 'check if child is a valid subdomain' do
       assert_raise Domain::NotAChild do
         Domain.replace_ds(@domain.name, 'dnssec.example.net', @ds)
       end
     end
 
   end
 
   class BulkTest < ActiveSupport::TestCase
     def setup
       @domain = create(:domain)
       @a = create(:a, domain: @domain)
       @aaaa = create(:aaaa, domain: @domain)
       @new = build(:mx, domain: @domain)
 
     end
 
     def valid_changes
       @valid_changes ||= begin
                            {}.tap { |c|
                              c[:deletes] = [@a.id]
                              c[:changes] = { @aaaa.id => { content: '::42' }}
                              c[:additions] = { 1 => @new.as_bulky_json }
                            }
                          end
     end
 
     def invalid_changes
       @invalid_changes ||= begin
                              {}.tap { |c|
                                c[:deletes] = [Record.maximum(:id) + 1]
                                c[:changes] = { @aaaa.id => { content: '1.2.3.4' }}
                                c[:additions] = { 1 => @new.as_bulky_json.update(prio: -1) }
                              }
                            end
     end
 
     test 'apply changes not' do
       ops, err = @domain.bulk invalid_changes
 
       assert_not_empty err
       assert_includes err[:deletes][Record.maximum(:id) + 1], 'record not found'
       assert_includes err[:changes][@aaaa.id], 'not a valid IPv6'
       assert_includes err[:additions][1], 'not a valid DNS priority'
     end
 
     test 'apply changes' do
       ops, err = @domain.bulk valid_changes
 
       @domain.reload
       @aaaa.reload
 
       assert_empty err
       assert_empty @domain.records.where(id: @a.id)
       assert_equal '::42', @aaaa.content
       assert_equal 1, @domain.records.where(type: :mx).count
       assert_equal 1, ops[:additions].size
       assert_equal 1, ops[:changes].size
       assert_equal 1, ops[:deletes].size
     end
   end
+
+  class ApiBulkTest < ActiveSupport::TestCase
+    def setup
+      @domain = create(:domain)
+      @a = create(:a, domain: @domain)
+      @aaaa = create(:aaaa, domain: @domain, content: '::42')
+      @new = build(:mx, domain: @domain)
+      @upsert_txt = build(:txt, domain: @domain)
+
+    end
+
+    def valid_changes
+      @valid_changes ||= begin
+                           {}.tap { |c|
+                             c[:deletes] = [@a.to_api]
+                             c[:additions] = [@new.to_api]
+                             c[:upserts] = [@upsert_txt.to_api]
+                           }
+                         end
+    end
+
+    test 'apply changes' do
+      ops, err = @domain.api_bulk valid_changes
+
+      @domain.reload
+      @aaaa.reload
+
+      assert_empty err
+      assert_empty @domain.records.where(id: @a.id)
+      assert_equal '::42', @aaaa.content
+      assert_equal 1, @domain.records.where(type: :mx).count
+      assert_equal 2, ops[:additions].size # upsert is accounted as in addition
+      assert_equal 1, ops[:deletes].size
+
+    end
+
+    test 'additions is invalid' do
+      changes = {
+        additions: [ @new.to_api.update(prio: -1) ]
+      }
+
+      ops, err = @domain.api_bulk changes
+
+      assert_not_empty err
+      assert_includes err[:additions].first[:error], 'not a valid DNS priority'
+    end
+
+
+    test 'delete not exists' do
+      changes = Hash[
+        :deletes, [{name: 'nx', type: 'TXT', content: 'not-exists'}]
+      ]
+
+      ops, err = @domain.api_bulk changes
+
+      assert_empty ops
+      assert_equal 1, err[:deletes].size
+    end
+
+    test 'upsert does not exist (single record)' do
+      a1 = create(:a, domain: @domain, name: 'rr', content: '127.0.0.1')
+
+      rr_name = "rr.#{@domain.name}"
+      changes = Hash[
+        :upserts, [{name: rr_name, type: 'A', content: '127.0.0.3'}]
+      ]
+
+      ops, err = @domain.api_bulk changes
+
+      assert_empty err
+      assert_equal 1, @domain.records.where(name: rr_name, type: 'A').count
+      assert_equal '127.0.0.3', @domain.records.find_by(name: rr_name, type: 'A').content
+    end
+
+    test 'upsert does not exist (multiple records)' do
+      a1 = create(:a, domain: @domain, name: 'rr', content: '127.0.0.1')
+      a2 = create(:a, domain: @domain, name: 'rr', content: '127.0.0.2')
+
+      rr_name = "rr.#{@domain.name}"
+      changes = Hash[
+        :upserts, [{name: rr_name, type: 'A', content: '127.0.0.3'}]
+      ]
+
+      ops, err = @domain.api_bulk changes
+
+      assert_empty err
+      assert_equal 1, @domain.records.where(name: rr_name, type: 'A').count
+      assert_equal '127.0.0.3', @domain.records.find_by(name: rr_name, type: 'A').content
+    end
+
+    test 'upsert exists' do
+      a1 = create(:a, domain: @domain, name: 'rr', content: '127.0.0.1')
+
+      rr_name = "rr.#{@domain.name}"
+      changes = Hash[
+        :upserts, [{name: rr_name, type: 'A', content: '127.0.0.1'}]
+      ]
+
+      ops, err = @domain.api_bulk changes
+
+      assert_empty err
+      assert_empty ops # upsert is a noop
+      assert_equal 1, @domain.records.where(name: rr_name, type: 'A').count
+      assert_equal '127.0.0.1', @domain.records.find_by(name: rr_name, type: 'A').content
+    end
+  end
 end